From e9f7e2820761d141c0e14d6e49ead25a8fa6f455 Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Wed, 18 Jul 2018 17:54:14 +0200 Subject: [PATCH 01/30] Allowed work with labels on user-mode (issue #77) For these resources: - servers - templates - scripts - ssh_profiles - firewall_profiles --- api/labels/labels_api.go | 110 ++++++++++ api/labels/labels_api_mocked.go | 229 ++++++++++++++++++++ api/labels/labels_api_test.go | 33 +++ api/types/firewall_profiles.go | 13 +- api/types/labels.go | 14 ++ api/types/scripts.go | 13 +- api/types/servers.go | 23 +- api/types/ssh_profiles.go | 11 +- api/types/templates.go | 3 + blueprint/scripts/subcommands.go | 52 +++++ blueprint/templates/subcommands.go | 52 +++++ cloud/servers/subcommands.go | 52 +++++ cloud/ssh_profiles/subcommands.go | 52 +++++ cmd/firewall_profiles_cmd.go | 33 ++- cmd/labels_cmd.go | 255 +++++++++++++++++++++++ cmd/scripts_cmd.go | 35 +++- cmd/servers_cmd.go | 34 ++- cmd/ssh_profiles_cmd.go | 32 ++- cmd/template_cmd.go | 33 ++- labels/subcommands.go | 16 ++ main.go | 9 + network/firewall_profiles/subcommands.go | 52 +++++ testdata/labels_data.go | 24 +++ utils/format/textformatter.go | 27 ++- utils/utils.go | 42 ++++ 25 files changed, 1211 insertions(+), 38 deletions(-) create mode 100644 api/labels/labels_api.go create mode 100644 api/labels/labels_api_mocked.go create mode 100644 api/labels/labels_api_test.go create mode 100644 api/types/labels.go create mode 100644 cmd/labels_cmd.go create mode 100644 labels/subcommands.go create mode 100644 testdata/labels_data.go diff --git a/api/labels/labels_api.go b/api/labels/labels_api.go new file mode 100644 index 0000000..998b65b --- /dev/null +++ b/api/labels/labels_api.go @@ -0,0 +1,110 @@ +package labels + +import ( + "encoding/json" + "fmt" + + log "github.com/Sirupsen/logrus" + "github.com/ingrammicro/concerto/api/types" + "github.com/ingrammicro/concerto/utils" +) + +// LabelService manages polling operations +type LabelService struct { + concertoService utils.ConcertoService +} + +// NewLabelService returns a Concerto labels service +func NewLabelService(concertoService utils.ConcertoService) (*LabelService, error) { + if concertoService == nil { + return nil, fmt.Errorf("Must initialize ConcertoService before using it") + } + + return &LabelService{ + concertoService: concertoService, + }, nil +} + +// GetLabelList returns the list of labels as an array of Label +func (lbl *LabelService) GetLabelList() (labels []types.Label, err error) { + log.Debug("GetLabelList") + + data, status, err := lbl.concertoService.Get("/v1/labels") + if err != nil { + return nil, err + } + + if err = utils.CheckStandardStatus(status, data); err != nil { + return nil, err + } + + if err = json.Unmarshal(data, &labels); err != nil { + return nil, err + } + + // exclude internal labels (with a Namespace defined) + var filteredLabels []types.Label + for _, label := range labels { + if label.Namespace == "" { + filteredLabels = append(filteredLabels, label) + } + } + + return filteredLabels, nil +} + +// CreateLabel creates a label +func (lbl *LabelService) CreateLabel(labelVector *map[string]interface{}) (label *types.Label, err error) { + log.Debug("CreateLabel") + + data, status, err := lbl.concertoService.Post("/v1/labels/", labelVector) + if err != nil { + return nil, err + } + + if err = utils.CheckStandardStatus(status, data); err != nil { + return nil, err + } + + if err = json.Unmarshal(data, &label); err != nil { + return nil, err + } + + return label, nil +} + +// AddLabel assigns a single label from a single labelable resource +func (lbl *LabelService) AddLabel(labelVector *map[string]interface{}, labelID string) (labeledResources []types.LabeledResources, err error) { + log.Debug("AddLabel") + + data, status, err := lbl.concertoService.Post(fmt.Sprintf("/v1/labels/%s/resources", labelID), labelVector) + if err != nil { + return nil, err + } + + if err = utils.CheckStandardStatus(status, data); err != nil { + return nil, err + } + + if err = json.Unmarshal(data, &labeledResources); err != nil { + return nil, err + } + + return labeledResources, nil +} + +// RemoveLabel de-assigns a single label from a single labelable resource +func (lbl *LabelService) RemoveLabel(labelID string, resourceType string, resourceID string) error { + log.Debug("RemoveLabel") + + data, status, err := lbl.concertoService.Delete(fmt.Sprintf("v1/labels/%s/resources/%s/%s", labelID, resourceType, resourceID)) + if err != nil { + return err + } + + if err = utils.CheckStandardStatus(status, data); err != nil { + return err + } + + return nil +} diff --git a/api/labels/labels_api_mocked.go b/api/labels/labels_api_mocked.go new file mode 100644 index 0000000..bf2b615 --- /dev/null +++ b/api/labels/labels_api_mocked.go @@ -0,0 +1,229 @@ +package labels + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/ingrammicro/concerto/api/types" + "github.com/ingrammicro/concerto/utils" + "github.com/stretchr/testify/assert" +) + +// GetLabelListMocked test mocked function +func GetLabelListMocked(t *testing.T, labelsIn *[]types.Label) *[]types.Label { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewLabelService(cs) + assert.Nil(err, "Couldn't load label service") + assert.NotNil(ds, "Label service not instanced") + + // to json + dIn, err := json.Marshal(labelsIn) + assert.Nil(err, "Label test data corrupted") + + // call service + cs.On("Get", "/v1/labels").Return(dIn, 200, nil) + labelsOut, err := ds.GetLabelList() + assert.Nil(err, "Error getting labels list") + assert.Equal(*labelsIn, labelsOut, "GetLabelList returned different labels") + + return &labelsOut +} + +// GetLabelListFailErrMocked test mocked function +func GetLabelListFailErrMocked(t *testing.T, labelsIn *[]types.Label) *[]types.Label { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewLabelService(cs) + assert.Nil(err, "Couldn't load label service") + assert.NotNil(ds, "Label service not instanced") + + // to json + dIn, err := json.Marshal(labelsIn) + assert.Nil(err, "Label test data corrupted") + + // call service + cs.On("Get", "/v1/labels").Return(dIn, 200, fmt.Errorf("Mocked error")) + labelsOut, err := ds.GetLabelList() + + assert.NotNil(err, "We are expecting an error") + assert.Nil(labelsOut, "Expecting nil output") + assert.Equal(err.Error(), "Mocked error", "Error should be 'Mocked error'") + + return &labelsOut +} + +// GetLabelListFailStatusMocked test mocked function +func GetLabelListFailStatusMocked(t *testing.T, labelsIn *[]types.Label) *[]types.Label { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewLabelService(cs) + assert.Nil(err, "Couldn't load label service") + assert.NotNil(ds, "Label service not instanced") + + // to json + dIn, err := json.Marshal(labelsIn) + assert.Nil(err, "Label test data corrupted") + + // call service + cs.On("Get", "/v1/labels").Return(dIn, 499, nil) + labelsOut, err := ds.GetLabelList() + + assert.NotNil(err, "We are expecting an status code error") + assert.Nil(labelsOut, "Expecting nil output") + assert.Contains(err.Error(), "499", "Error should contain http code 499") + + return &labelsOut +} + +// GetLabelListFailJSONMocked test mocked function +func GetLabelListFailJSONMocked(t *testing.T, labelsIn *[]types.Label) *[]types.Label { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewLabelService(cs) + assert.Nil(err, "Couldn't load label service") + assert.NotNil(ds, "Label service not instanced") + + // wrong json + dIn := []byte{10, 20, 30} + + // call service + cs.On("Get", "/v1/labels").Return(dIn, 200, nil) + labelsOut, err := ds.GetLabelList() + + assert.NotNil(err, "We are expecting a marshalling error") + assert.Nil(labelsOut, "Expecting nil output") + assert.Contains(err.Error(), "invalid character", "Error message should include the string 'invalid character'") + + return &labelsOut +} + +// CreateLabelMocked test mocked function +func CreateLabelMocked(t *testing.T, labelIn *types.Label) *types.Label { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewLabelService(cs) + assert.Nil(err, "Couldn't load label service") + assert.NotNil(ds, "Label service not instanced") + + // convertMap + mapIn, err := utils.ItemConvertParams(*labelIn) + assert.Nil(err, "Label test data corrupted") + + // to json + dOut, err := json.Marshal(labelIn) + assert.Nil(err, "Label test data corrupted") + + // call service + cs.On("Post", "/v1/labels/", mapIn).Return(dOut, 200, nil) + labelOut, err := ds.CreateLabel(mapIn) + assert.Nil(err, "Error creating label") + assert.Equal(labelIn, labelOut, "CreateLabel returned different labels") + + return labelOut +} + +// CreateLabelFailErrMocked test mocked function +func CreateLabelFailErrMocked(t *testing.T, labelIn *types.Label) *types.Label { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewLabelService(cs) + assert.Nil(err, "Couldn't load label service") + assert.NotNil(ds, "Label service not instanced") + + // convertMap + mapIn, err := utils.ItemConvertParams(*labelIn) + assert.Nil(err, "Label test data corrupted") + + // to json + dOut, err := json.Marshal(labelIn) + assert.Nil(err, "Label test data corrupted") + + // call service + cs.On("Post", "/v1/labels/", mapIn).Return(dOut, 200, fmt.Errorf("Mocked error")) + labelOut, err := ds.CreateLabel(mapIn) + + assert.NotNil(err, "We are expecting an error") + assert.Nil(labelOut, "Expecting nil output") + assert.Equal(err.Error(), "Mocked error", "Error should be 'Mocked error'") + + return labelOut +} + +// CreateLabelFailStatusMocked test mocked function +func CreateLabelFailStatusMocked(t *testing.T, labelIn *types.Label) *types.Label { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewLabelService(cs) + assert.Nil(err, "Couldn't load label service") + assert.NotNil(ds, "Label service not instanced") + + // convertMap + mapIn, err := utils.ItemConvertParams(*labelIn) + assert.Nil(err, "Label test data corrupted") + + // to json + dOut, err := json.Marshal(labelIn) + assert.Nil(err, "Label test data corrupted") + + // call service + cs.On("Post", "/v1/labels/", mapIn).Return(dOut, 499, nil) + labelOut, err := ds.CreateLabel(mapIn) + + assert.NotNil(err, "We are expecting an status code error") + assert.Nil(labelOut, "Expecting nil output") + assert.Contains(err.Error(), "499", "Error should contain http code 499") + + return labelOut +} + +// // CreateLabelFailJSONMocked test mocked function +// func CreateLabelFailJSONMocked(t *testing.T, labelIn *types.Label) *types.Label { + +// assert := assert.New(t) + +// // wire up +// cs := &utils.MockConcertoService{} +// ds, err := NewLabelService(cs) +// assert.Nil(err, "Couldn't load label service") +// assert.NotNil(ds, "Label service not instanced") + +// // convertMap +// mapIn, err := utils.ItemConvertParams(*labelIn) +// assert.Nil(err, "Label test data corrupted") + +// // wrong json +// dIn := []byte{10, 20, 30} + +// // call service +// cs.On("Post", "/v1/cloud/servers/", mapIn).Return(dIn, 200, nil) +// labelOut, err := ds.CreateLabel(mapIn) + +// assert.NotNil(err, "We are expecting a marshalling error") +// assert.Nil(labelOut, "Expecting nil output") +// assert.Contains(err.Error(), "invalid character", "Error message should include the string 'invalid character'") + +// return labelOut +// } diff --git a/api/labels/labels_api_test.go b/api/labels/labels_api_test.go new file mode 100644 index 0000000..8999aa9 --- /dev/null +++ b/api/labels/labels_api_test.go @@ -0,0 +1,33 @@ +package labels + +import ( + "testing" + + "github.com/ingrammicro/concerto/testdata" + "github.com/stretchr/testify/assert" +) + +func TestNewLabelServiceNil(t *testing.T) { + assert := assert.New(t) + rs, err := NewLabelService(nil) + assert.Nil(rs, "Uninitialized service should return nil") + assert.NotNil(err, "Uninitialized service should return error") +} + +func TestGetCloudProviderList(t *testing.T) { + labelsIn := testdata.GetLabelData() + GetLabelListMocked(t, labelsIn) + GetLabelListFailErrMocked(t, labelsIn) + GetLabelListFailStatusMocked(t, labelsIn) + GetLabelListFailJSONMocked(t, labelsIn) +} + +func TestCreateLabel(t *testing.T) { + labelsIn := testdata.GetLabelData() + for _, labelIn := range *labelsIn { + CreateLabelMocked(t, &labelIn) + CreateLabelFailErrMocked(t, &labelIn) + CreateLabelFailStatusMocked(t, &labelIn) + //CreateLabelFailJSONMocked(t, &labelIn) + } +} diff --git a/api/types/firewall_profiles.go b/api/types/firewall_profiles.go index ef771d6..93230b7 100644 --- a/api/types/firewall_profiles.go +++ b/api/types/firewall_profiles.go @@ -1,11 +1,14 @@ package types type FirewallProfile struct { - ID string `json:"id" header:"ID"` - Name string `json:"name,omitempty" header:"NAME"` - Description string `json:"description,omitempty" header:"DESCRIPTION"` - Default bool `json:"default,omitempty" header:"DEFAULT"` - Rules []Rule `json:"rules,omitempty" header:"RULES" show:"nolist"` + ID string `json:"id" header:"ID"` + Name string `json:"name,omitempty" header:"NAME"` + Description string `json:"description,omitempty" header:"DESCRIPTION"` + Default bool `json:"default,omitempty" header:"DEFAULT"` + Rules []Rule `json:"rules,omitempty" header:"RULES" show:"nolist"` + ResourceType string `json:"resource_type" header:"RESOURCE_TYPE" show:"nolist"` + LabelIDs []string `json:"label_ids" header:"LABEL_IDS" show:"nolist,noshow"` + Labels []string `json:"labels" header:"LABELS"` } type Rule struct { diff --git a/api/types/labels.go b/api/types/labels.go new file mode 100644 index 0000000..b079714 --- /dev/null +++ b/api/types/labels.go @@ -0,0 +1,14 @@ +package types + +type Label struct { + ID string `json:"id" header:"ID"` + Name string `json:"name" header:"NAME"` + ResourceType string `json:"resource_type" header:"RESOURCE_TYPE"` + Namespace string `json:"namespace" header:"NAMESPACE" show:"nolist"` + Value string `json:"value" header:"VALUE" show:"nolist"` +} + +type LabeledResources struct { + ID string `json:"id" header:"ID"` + ResourceType string `json:"resource_type" header:"RESOURCE_TYPE"` +} diff --git a/api/types/scripts.go b/api/types/scripts.go index 3f1b011..05fbd9d 100644 --- a/api/types/scripts.go +++ b/api/types/scripts.go @@ -2,9 +2,12 @@ package types // Script holds script data type Script struct { - ID string `json:"id" header:"ID"` - Name string `json:"name" header:"NAME"` - Description string `json:"description" header:"DESCRIPTION"` - Code string `json:"code" header:"CODE" show:"nolist"` - Parameters []string `json:"parameters" header:"PARAMETERS"` + ID string `json:"id" header:"ID"` + Name string `json:"name" header:"NAME"` + Description string `json:"description" header:"DESCRIPTION"` + Code string `json:"code" header:"CODE" show:"nolist"` + Parameters []string `json:"parameters" header:"PARAMETERS"` + ResourceType string `json:"resource_type" header:"RESOURCE_TYPE" show:"nolist"` + LabelIDs []string `json:"label_ids" header:"LABEL_IDS" show:"nolist,noshow"` + Labels []string `json:"labels" header:"LABELS"` } diff --git a/api/types/servers.go b/api/types/servers.go index e752b6b..c008875 100644 --- a/api/types/servers.go +++ b/api/types/servers.go @@ -1,16 +1,19 @@ package types type Server struct { - ID string `json:"id" header:"ID"` - Name string `json:"name" header:"NAME"` - Fqdn string `json:"fqdn" header:"FQDN"` - State string `json:"state" header:"STATE"` - PublicIP string `json:"public_ip" header:"PUBLIC_IP"` - TemplateID string `json:"template_id" header:"TEMPLATE_ID"` - ServerPlanID string `json:"server_plan_id" header:"SERVER_PLAN_ID"` - CloudAccountID string `json:"cloud_account_id" header:"CLOUD_ACCOUNT_ID"` - SSHProfileID string `json:"ssh_profile_id" header:"SSH_PROFILE_ID"` - FirewallProfileID string `json:"firewall_profile_id" header:"FIREWALL_PROFILE_ID"` + ID string `json:"id" header:"ID"` + Name string `json:"name" header:"NAME"` + Fqdn string `json:"fqdn" header:"FQDN"` + State string `json:"state" header:"STATE"` + PublicIP string `json:"public_ip" header:"PUBLIC_IP"` + TemplateID string `json:"template_id" header:"TEMPLATE_ID"` + ServerPlanID string `json:"server_plan_id" header:"SERVER_PLAN_ID"` + CloudAccountID string `json:"cloud_account_id" header:"CLOUD_ACCOUNT_ID"` + SSHProfileID string `json:"ssh_profile_id" header:"SSH_PROFILE_ID"` + FirewallProfileID string `json:"firewall_profile_id" header:"FIREWALL_PROFILE_ID"` + ResourceType string `json:"resource_type" header:"RESOURCE_TYPE" show:"nolist"` + LabelIDs []string `json:"label_ids" header:"LABEL_IDS" show:"nolist,noshow"` + Labels []string `json:"labels" header:"LABELS"` } type Dns struct { diff --git a/api/types/ssh_profiles.go b/api/types/ssh_profiles.go index ef9c3cb..04a2091 100644 --- a/api/types/ssh_profiles.go +++ b/api/types/ssh_profiles.go @@ -1,8 +1,11 @@ package types type SSHProfile struct { - ID string `json:"id" header:"ID"` - Name string `json:"name" heade:"NAME"` - PublicKey string `json:"public_key" header:"PUBLIC_KEY"` - PrivateKey string `json:"private_key" header:"PRIVATE_KEY"` + ID string `json:"id" header:"ID"` + Name string `json:"name" header:"NAME"` + PublicKey string `json:"public_key" header:"PUBLIC_KEY"` + PrivateKey string `json:"private_key" header:"PRIVATE_KEY" show:"nolist"` + ResourceType string `json:"resource_type" header:"RESOURCE_TYPE" show:"nolist"` + LabelIDs []string `json:"label_ids" header:"LABEL_IDS" show:"nolist,noshow"` + Labels []string `json:"labels" header:"LABELS"` } diff --git a/api/types/templates.go b/api/types/templates.go index 52ecfec..85ff931 100644 --- a/api/types/templates.go +++ b/api/types/templates.go @@ -11,6 +11,9 @@ type Template struct { GenericImageID string `json:"generic_image_id,omitempty" header:"GENERIC IMAGE ID"` ServiceList []string `json:"service_list,omitempty" header:"SERVICE LIST" show:"nolist"` ConfigurationAttributes *json.RawMessage `json:"configuration_attributes,omitempty" header:"CONFIGURATION ATTRIBUTES" show:"nolist"` + ResourceType string `json:"resource_type" header:"RESOURCE_TYPE" show:"nolist"` + LabelIDs []string `json:"label_ids" header:"LABEL_IDS" show:"nolist,noshow"` + Labels []string `json:"labels" header:"LABELS"` } // TemplateScript stores a templates' script info diff --git a/blueprint/scripts/subcommands.go b/blueprint/scripts/subcommands.go index f635a09..d5f5aec 100644 --- a/blueprint/scripts/subcommands.go +++ b/blueprint/scripts/subcommands.go @@ -11,6 +11,12 @@ func SubCommands() []cli.Command { Name: "list", Usage: "Lists all available scripts", Action: cmd.ScriptsList, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "labels", + Usage: "A list of comma separated label as a query filter", + }, + }, }, { Name: "show", @@ -44,6 +50,10 @@ func SubCommands() []cli.Command { Name: "parameters", Usage: "The names of the script's parameters", }, + cli.StringFlag{ + Name: "labels", + Usage: "A list of comma separated label names to be associated with script", + }, }, }, { @@ -84,5 +94,47 @@ func SubCommands() []cli.Command { }, }, }, + { + Name: "add-label", + Usage: "This action assign a single label from a single labelable resource", + Action: cmd.LabelAdd, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "id", + Usage: "Script Id", + }, + cli.StringFlag{ + Name: "label", + Usage: "Label name", + }, + cli.StringFlag{ + Name: "resource_type", + Usage: "Resource Type", + Value: "script", + Hidden: true, + }, + }, + }, + { + Name: "remove-label", + Usage: "This action de-assign a single label from a single labelable resource", + Action: cmd.LabelRemove, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "id", + Usage: "Script Id", + }, + cli.StringFlag{ + Name: "label", + Usage: "Label name", + }, + cli.StringFlag{ + Name: "resource_type", + Usage: "Resource Type", + Value: "script", + Hidden: true, + }, + }, + }, } } diff --git a/blueprint/templates/subcommands.go b/blueprint/templates/subcommands.go index d3c632e..3c03c6c 100644 --- a/blueprint/templates/subcommands.go +++ b/blueprint/templates/subcommands.go @@ -11,6 +11,12 @@ func SubCommands() []cli.Command { Name: "list", Usage: "Lists all available templates", Action: cmd.TemplateList, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "labels", + Usage: "A list of comma separated label as a query filter", + }, + }, }, { Name: "show", @@ -44,6 +50,10 @@ func SubCommands() []cli.Command { Name: "configuration_attributes", Usage: "The attributes used to configure the services in the service_list", }, + cli.StringFlag{ + Name: "labels", + Usage: "A list of comma separated label names to be associated with template", + }, }, }, { @@ -201,5 +211,47 @@ func SubCommands() []cli.Command { }, }, }, + { + Name: "add-label", + Usage: "This action assign a single label from a single labelable resource", + Action: cmd.LabelAdd, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "id", + Usage: "Template Id", + }, + cli.StringFlag{ + Name: "label", + Usage: "Label name", + }, + cli.StringFlag{ + Name: "resource_type", + Usage: "Resource Type", + Value: "template", + Hidden: true, + }, + }, + }, + { + Name: "remove-label", + Usage: "This action de-assign a single label from a single labelable resource", + Action: cmd.LabelRemove, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "id", + Usage: "Template Id", + }, + cli.StringFlag{ + Name: "label", + Usage: "Label name", + }, + cli.StringFlag{ + Name: "resource_type", + Usage: "Resource Type", + Value: "template", + Hidden: true, + }, + }, + }, } } diff --git a/cloud/servers/subcommands.go b/cloud/servers/subcommands.go index aba1889..e419ad3 100644 --- a/cloud/servers/subcommands.go +++ b/cloud/servers/subcommands.go @@ -11,6 +11,12 @@ func SubCommands() []cli.Command { Name: "list", Usage: "Lists information about all the servers on this account.", Action: cmd.ServerList, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "labels", + Usage: "A list of comma separated label as a query filter", + }, + }, }, { Name: "show", @@ -52,6 +58,10 @@ func SubCommands() []cli.Command { Name: "cloud_account_id", Usage: "Identifier of the cloud account in which the server shall be registered", }, + cli.StringFlag{ + Name: "labels", + Usage: "A list of comma separated label names to be associated with server", + }, }, }, { @@ -162,5 +172,47 @@ func SubCommands() []cli.Command { }, }, }, + { + Name: "add-label", + Usage: "This action assign a single label from a single labelable resource", + Action: cmd.LabelAdd, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "id", + Usage: "Server Id", + }, + cli.StringFlag{ + Name: "label", + Usage: "Label name", + }, + cli.StringFlag{ + Name: "resource_type", + Usage: "Resource Type", + Value: "server", + Hidden: true, + }, + }, + }, + { + Name: "remove-label", + Usage: "This action de-assign a single label from a single labelable resource", + Action: cmd.LabelRemove, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "id", + Usage: "Server Id", + }, + cli.StringFlag{ + Name: "label", + Usage: "Label name", + }, + cli.StringFlag{ + Name: "resource_type", + Usage: "Resource Type", + Value: "server", + Hidden: true, + }, + }, + }, } } diff --git a/cloud/ssh_profiles/subcommands.go b/cloud/ssh_profiles/subcommands.go index ef5af9f..9b8eddb 100644 --- a/cloud/ssh_profiles/subcommands.go +++ b/cloud/ssh_profiles/subcommands.go @@ -11,6 +11,12 @@ func SubCommands() []cli.Command { Name: "list", Usage: "Lists all available SSH profiles.", Action: cmd.SSHProfileList, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "labels", + Usage: "A list of comma separated label as a query filter", + }, + }, }, { Name: "show", @@ -40,6 +46,10 @@ func SubCommands() []cli.Command { Name: "private_key", Usage: "Private key of the SSH profile", }, + cli.StringFlag{ + Name: "labels", + Usage: "A list of comma separated label names to be associated with SSH profile", + }, }, }, { @@ -76,5 +86,47 @@ func SubCommands() []cli.Command { }, }, }, + { + Name: "add-label", + Usage: "This action assign a single label from a single labelable resource", + Action: cmd.LabelAdd, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "id", + Usage: "SSH profile id", + }, + cli.StringFlag{ + Name: "label", + Usage: "Label name", + }, + cli.StringFlag{ + Name: "resource_type", + Usage: "Resource Type", + Value: "ssh_profile", + Hidden: true, + }, + }, + }, + { + Name: "remove-label", + Usage: "This action de-assign a single label from a single labelable resource", + Action: cmd.LabelRemove, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "id", + Usage: "SSH profile id", + }, + cli.StringFlag{ + Name: "label", + Usage: "Label name", + }, + cli.StringFlag{ + Name: "resource_type", + Usage: "Resource Type", + Value: "ssh_profile", + Hidden: true, + }, + }, + }, } } diff --git a/cmd/firewall_profiles_cmd.go b/cmd/firewall_profiles_cmd.go index 94e65f0..0652033 100644 --- a/cmd/firewall_profiles_cmd.go +++ b/cmd/firewall_profiles_cmd.go @@ -3,6 +3,7 @@ package cmd import ( "github.com/codegangsta/cli" "github.com/ingrammicro/concerto/api/network" + "github.com/ingrammicro/concerto/api/types" "github.com/ingrammicro/concerto/utils" "github.com/ingrammicro/concerto/utils/format" ) @@ -37,6 +38,19 @@ func FirewallProfileList(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't receive firewallProfile data", err) } + + filteredResources, err := LabelFiltering(c, firewallProfiles) + if err != nil { + formatter.PrintFatal("Couldn't list firewall profiles filtered by labels", err) + } + if filteredResources != nil { + firewallProfiles = nil + for _, v := range *filteredResources { + firewallProfiles = append(firewallProfiles, v.(types.FirewallProfile)) + } + } + + LabelAssignNamesForIDs(c, firewallProfiles) if err = formatter.PrintList(firewallProfiles); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -53,6 +67,8 @@ func FirewallProfileShow(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't receive firewallProfile data", err) } + + LabelAssignNamesForIDs(c, firewallProfile) if err = formatter.PrintItem(*firewallProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -69,10 +85,25 @@ func FirewallProfileCreate(c *cli.Context) error { if err != nil { formatter.PrintFatal("Error parsing parameters", err) } - firewallProfile, err := firewallProfileSvc.CreateFirewallProfile(params) + + firewallProfileIn := map[string]interface{}{ + "name": c.String("name"), + "description": c.String("description"), + } + if c.String("rules") != "" { + firewallProfileIn["rules"] = (*params)["rules"] + } + if c.IsSet("labels") { + labelsIdsArr := LabelResolution(c, c.String("labels")) + firewallProfileIn["label_ids"] = labelsIdsArr + } + + firewallProfile, err := firewallProfileSvc.CreateFirewallProfile(&firewallProfileIn) if err != nil { formatter.PrintFatal("Couldn't create firewallProfile", err) } + + LabelAssignNamesForIDs(c, firewallProfile) if err = formatter.PrintItem(*firewallProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } diff --git a/cmd/labels_cmd.go b/cmd/labels_cmd.go new file mode 100644 index 0000000..5915e14 --- /dev/null +++ b/cmd/labels_cmd.go @@ -0,0 +1,255 @@ +package cmd + +import ( + "fmt" + "reflect" + "regexp" + "strings" + + "github.com/codegangsta/cli" + "github.com/ingrammicro/concerto/api/labels" + "github.com/ingrammicro/concerto/utils" + "github.com/ingrammicro/concerto/utils/format" +) + +// WireUpLabel prepares common resources to send request to Concerto API +func WireUpLabel(c *cli.Context) (ds *labels.LabelService, f format.Formatter) { + + f = format.GetFormatter() + + config, err := utils.GetConcertoConfig() + if err != nil { + f.PrintFatal("Couldn't wire up config", err) + } + hcs, err := utils.NewHTTPConcertoService(config) + if err != nil { + f.PrintFatal("Couldn't wire up concerto service", err) + } + ds, err = labels.NewLabelService(hcs) + if err != nil { + f.PrintFatal("Couldn't wire up label service", err) + } + + return ds, f +} + +// LabelList subcommand function +func LabelList(c *cli.Context) error { + debugCmdFuncInfo(c) + + labelsSvc, formatter := WireUpLabel(c) + labels, err := labelsSvc.GetLabelList() + if err != nil { + formatter.PrintFatal("Couldn't receive labels data", err) + } + + if err = formatter.PrintList(labels); err != nil { + formatter.PrintFatal("Couldn't print/format result", err) + } + return nil +} + +// LabelCreate subcommand function +func LabelCreate(c *cli.Context) error { + debugCmdFuncInfo(c) + + labelsSvc, formatter := WireUpLabel(c) + checkRequiredFlags(c, []string{"name", "resource_type"}, formatter) + label, err := labelsSvc.CreateLabel(utils.FlagConvertParams(c)) + if err != nil { + formatter.PrintFatal("Couldn't create label", err) + } + if err = formatter.PrintItem(*label); err != nil { + formatter.PrintFatal("Couldn't print/format result", err) + } + return nil +} + +// LabelFiltering subcommand function receives an interface representing a collection of labelable resources (Server, Template, ...) +// Evaluates the matching of assigned labels with the labels requested for filtering. +func LabelFiltering(c *cli.Context, items interface{}) (*[]interface{}, error) { + debugCmdFuncInfo(c) + + if c.String("labels") != "" { + its := reflect.ValueOf(items) + if its.Type().Kind() != reflect.Slice { + return nil, fmt.Errorf("Cannot process label filtering. Slice expected") + } + + // evaluates labels + _, formatter := WireUpLabel(c) + labelNamesIn := LabelsUnifyInputNames(c.String("labels"), formatter) + + // Load Labels mapping ID <-> NAME + _, labelsMapIDToName := LabelLoadsMapping(c) + + var filteredResources []interface{} + var tmpLabelNames []string + // per resource (Server, Template, ...) + for i := 0; i < its.Len(); i++ { + tmpLabelNames = nil + labelIDs := reflect.ValueOf(its.Index(i).FieldByName("LabelIDs").Interface()) + if len := labelIDs.Len(); len > 0 { + for j := 0; j < len; j++ { + tmpLabelNames = append(tmpLabelNames, labelsMapIDToName[labelIDs.Index(j).String()]) + } + } + // checks whether received labels match for resources labels + if utils.Subset(labelNamesIn, tmpLabelNames) { + filteredResources = append(filteredResources, its.Index(i).Interface()) + } + } + return &filteredResources, nil + } + return nil, nil +} + +// LabelAssignNamesForIDs subcommand function receives an interface representing labelable resources (Server, Template, ...) +// Resolves the Labels names associated to a each resource from given Labels ids, loading object with respective labels names +func LabelAssignNamesForIDs(c *cli.Context, items interface{}) { + debugCmdFuncInfo(c) + + var tmpLabelNames []string + + // Load Labels mapping ID <-> NAME + _, labelsMapIDToName := LabelLoadsMapping(c) + + its := reflect.ValueOf(items) + if its.Type().Kind() == reflect.Slice { // resources collection + // per resource (Server, Template, ...) + for i := 0; i < its.Len(); i++ { + tmpLabelNames = nil + labelIDs := reflect.ValueOf(its.Index(i).FieldByName("LabelIDs").Interface()) + if len := labelIDs.Len(); len > 0 { + for j := 0; j < len; j++ { + tmpLabelNames = append(tmpLabelNames, labelsMapIDToName[labelIDs.Index(j).String()]) + } + } + its.Index(i).FieldByName("Labels").Set(reflect.ValueOf(tmpLabelNames)) + } + } else if its.Type().Kind() == reflect.Ptr { // resource + labelIDs := reflect.Indirect(its).FieldByName("LabelIDs") + if len := labelIDs.Len(); len > 0 { + for j := 0; j < len; j++ { + tmpLabelNames = append(tmpLabelNames, labelsMapIDToName[labelIDs.Index(j).String()]) + } + } + reflect.Indirect(its).FieldByName("Labels").Set(reflect.ValueOf(tmpLabelNames)) + } +} + +// LabelLoadsMapping subcommand function retrieves the current label list in IMCO; then prepares two mapping structures (Name <-> ID and ID <-> Name) +func LabelLoadsMapping(c *cli.Context) (map[string]string, map[string]string) { + debugCmdFuncInfo(c) + + labelsSvc, formatter := WireUpLabel(c) + labels, err := labelsSvc.GetLabelList() + if err != nil { + formatter.PrintFatal("Couldn't receive labels data", err) + } + + labelsMapNameToID := make(map[string]string) + labelsMapIDToName := make(map[string]string) + + for _, label := range labels { + labelsMapNameToID[label.Name] = label.ID + labelsMapIDToName[label.ID] = label.Name + } + return labelsMapNameToID, labelsMapIDToName +} + +// LabelsUnifyInputNames subcommand function evaluates the received labels names (comma separated string). +// Validates, remove duplicates and resolves a slice with unique label names. +func LabelsUnifyInputNames(labelsNames string, formatter format.Formatter) []string { + labelNamesIn := utils.RemoveDuplicates(strings.Split(labelsNames, ",")) + for _, c := range labelNamesIn { + matched := regexp.MustCompile(`^[A-Za-z0-9 .\s_-]+$`).MatchString(c) + if !matched { + formatter.PrintFatal("Invalid label name ", fmt.Errorf("Invalid label format: %v (Labels would be indicated with their name, which must satisfy to be composed of spaces, underscores, dots, dashes and/or lower/upper -case alphanumeric characters-)", c)) + } + } + return labelNamesIn +} + +// LabelResolution subcommand function retrieves a labels map(Name<->ID) based on label names received to be procesed. +// The function evaluates the received labels names (comma separated string); with them, solves the assigned IDs for the given labels names. +// If the label name is not avaiable in IMCO yet, it is created. +func LabelResolution(c *cli.Context, labelsNames string) []string { + debugCmdFuncInfo(c) + + labelsSvc, formatter := WireUpLabel(c) + labelNamesIn := LabelsUnifyInputNames(labelsNames, formatter) + labelsMapNameToID, _ := LabelLoadsMapping(c) + + // Obtain output mapped labels Name<->ID; currenlty in IMCO platform as well as if creation is required + labelsOutMap := make(map[string]string) + for _, name := range labelNamesIn { + // check if the label already exists in IMCO, creates it if it does not exist + if labelsMapNameToID[name] == "" { + labelPayload := make(map[string]interface{}) + labelPayload["name"] = name + newLabel, err := labelsSvc.CreateLabel(&labelPayload) + if err != nil { + formatter.PrintFatal("Couldn't create label", err) + } + labelsOutMap[name] = newLabel.ID + } else { + labelsOutMap[name] = labelsMapNameToID[name] + } + } + labelsIdsArr := make([]string, 0) + for _, mp := range labelsOutMap { + labelsIdsArr = append(labelsIdsArr, mp) + } + return labelsIdsArr +} + +// LabelAdd subcommand function assigns a single label from a single labelable resource +func LabelAdd(c *cli.Context) error { + debugCmdFuncInfo(c) + + labelsSvc, formatter := WireUpLabel(c) + checkRequiredFlags(c, []string{"id", "label"}, formatter) + + labelsIdsArr := LabelResolution(c, c.String("label")) + if len(labelsIdsArr) > 1 { + formatter.PrintFatal("Too many label names. Please, Use only one label name", fmt.Errorf("Invalid parameter: %v - %v", c.String("label"), labelsIdsArr)) + } + labelID := labelsIdsArr[0] + + resData := make(map[string]string) + resData["id"] = c.String("id") + resData["resource_type"] = c.String("resource_type") + resourcesData := make([]interface{}, 0, 1) + resourcesData = append(resourcesData, resData) + + labelIn := map[string]interface{}{ + "resources": resourcesData, + } + + labeledResources, err := labelsSvc.AddLabel(&labelIn, labelID) + if err != nil { + formatter.PrintFatal("Couldn't add label data", err) + } + if err = formatter.PrintList(labeledResources); err != nil { + formatter.PrintFatal("Couldn't print/format result", err) + } + return nil +} + +// LabelRemove subcommand function de-assigns a single label from a single labelable resource +func LabelRemove(c *cli.Context) error { + debugCmdFuncInfo(c) + + labelsSvc, formatter := WireUpLabel(c) + checkRequiredFlags(c, []string{"id", "label"}, formatter) + + labelsMapNameToID, _ := LabelLoadsMapping(c) + labelID := labelsMapNameToID[c.String("label")] + + err := labelsSvc.RemoveLabel(labelID, c.String("resource_type"), c.String("id")) + if err != nil { + formatter.PrintFatal("Couldn't remove label", err) + } + return nil +} diff --git a/cmd/scripts_cmd.go b/cmd/scripts_cmd.go index 2568f03..763f6b9 100644 --- a/cmd/scripts_cmd.go +++ b/cmd/scripts_cmd.go @@ -1,8 +1,11 @@ package cmd import ( + "strings" + "github.com/codegangsta/cli" "github.com/ingrammicro/concerto/api/blueprint" + "github.com/ingrammicro/concerto/api/types" "github.com/ingrammicro/concerto/utils" "github.com/ingrammicro/concerto/utils/format" ) @@ -37,6 +40,19 @@ func ScriptsList(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't receive script data", err) } + + filteredResources, err := LabelFiltering(c, scripts) + if err != nil { + formatter.PrintFatal("Couldn't list scripts filtered by labels", err) + } + if filteredResources != nil { + scripts = nil + for _, v := range *filteredResources { + scripts = append(scripts, v.(types.Script)) + } + } + + LabelAssignNamesForIDs(c, scripts) if err = formatter.PrintList(scripts); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -53,6 +69,8 @@ func ScriptShow(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't receive script data", err) } + + LabelAssignNamesForIDs(c, script) if err = formatter.PrintItem(*script); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -65,10 +83,25 @@ func ScriptCreate(c *cli.Context) error { scriptSvc, formatter := WireUpScript(c) checkRequiredFlags(c, []string{"name", "description", "code"}, formatter) - script, err := scriptSvc.CreateScript(utils.FlagConvertParams(c)) + scriptIn := map[string]interface{}{ + "name": c.String("name"), + "description": c.String("description"), + "code": c.String("code"), + } + if c.String("parameters") != "" { + scriptIn["parameters"] = strings.Split(c.String("parameters"), ",") + } + if c.IsSet("labels") { + labelsIdsArr := LabelResolution(c, c.String("labels")) + scriptIn["label_ids"] = labelsIdsArr + } + + script, err := scriptSvc.CreateScript(&scriptIn) if err != nil { formatter.PrintFatal("Couldn't create script", err) } + + LabelAssignNamesForIDs(c, script) if err = formatter.PrintItem(*script); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } diff --git a/cmd/servers_cmd.go b/cmd/servers_cmd.go index 5041e8f..b6fa420 100644 --- a/cmd/servers_cmd.go +++ b/cmd/servers_cmd.go @@ -3,6 +3,7 @@ package cmd import ( "github.com/codegangsta/cli" "github.com/ingrammicro/concerto/api/cloud" + "github.com/ingrammicro/concerto/api/types" "github.com/ingrammicro/concerto/utils" "github.com/ingrammicro/concerto/utils/format" ) @@ -37,6 +38,19 @@ func ServerList(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't receive server data", err) } + + filteredResources, err := LabelFiltering(c, servers) + if err != nil { + formatter.PrintFatal("Couldn't list servers filtered by labels", err) + } + if filteredResources != nil { + servers = nil + for _, v := range *filteredResources { + servers = append(servers, v.(types.Server)) + } + } + + LabelAssignNamesForIDs(c, servers) if err = formatter.PrintList(servers); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -53,6 +67,8 @@ func ServerShow(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't receive server data", err) } + + LabelAssignNamesForIDs(c, server) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -65,10 +81,26 @@ func ServerCreate(c *cli.Context) error { serverSvc, formatter := WireUpServer(c) checkRequiredFlags(c, []string{"name", "ssh_profile_id", "firewall_profile_id", "template_id", "server_plan_id", "cloud_account_id"}, formatter) - server, err := serverSvc.CreateServer(utils.FlagConvertParams(c)) + serverIn := map[string]interface{}{ + "name": c.String("name"), + "ssh_profile_id": c.String("ssh_profile_id"), + "firewall_profile_id": c.String("firewall_profile_id"), + "template_id": c.String("template_id"), + "server_plan_id": c.String("server_plan_id"), + "cloud_account_id": c.String("cloud_account_id"), + } + + if c.IsSet("labels") { + labelsIdsArr := LabelResolution(c, c.String("labels")) + serverIn["label_ids"] = labelsIdsArr + } + + server, err := serverSvc.CreateServer(&serverIn) if err != nil { formatter.PrintFatal("Couldn't create server", err) } + + LabelAssignNamesForIDs(c, server) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } diff --git a/cmd/ssh_profiles_cmd.go b/cmd/ssh_profiles_cmd.go index 4010b45..4497e6c 100644 --- a/cmd/ssh_profiles_cmd.go +++ b/cmd/ssh_profiles_cmd.go @@ -3,6 +3,7 @@ package cmd import ( "github.com/codegangsta/cli" "github.com/ingrammicro/concerto/api/cloud" + "github.com/ingrammicro/concerto/api/types" "github.com/ingrammicro/concerto/utils" "github.com/ingrammicro/concerto/utils/format" ) @@ -37,6 +38,19 @@ func SSHProfileList(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't receive sshProfile data", err) } + + filteredResources, err := LabelFiltering(c, sshProfiles) + if err != nil { + formatter.PrintFatal("Couldn't list SSH profiles filtered by labels", err) + } + if filteredResources != nil { + sshProfiles = nil + for _, v := range *filteredResources { + sshProfiles = append(sshProfiles, v.(types.SSHProfile)) + } + } + + LabelAssignNamesForIDs(c, sshProfiles) if err = formatter.PrintList(sshProfiles); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -53,6 +67,8 @@ func SSHProfileShow(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't receive sshProfile data", err) } + + LabelAssignNamesForIDs(c, sshProfile) if err = formatter.PrintItem(*sshProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -65,10 +81,24 @@ func SSHProfileCreate(c *cli.Context) error { sshProfileSvc, formatter := WireUpSSHProfile(c) checkRequiredFlags(c, []string{"name", "public_key"}, formatter) - sshProfile, err := sshProfileSvc.CreateSSHProfile(utils.FlagConvertParams(c)) + sshProfileIn := map[string]interface{}{ + "name": c.String("name"), + "public_key": c.String("public_key"), + } + if c.String("private_key") != "" { + sshProfileIn["private_key"] = c.String("private_key") + } + if c.IsSet("labels") { + labelsIdsArr := LabelResolution(c, c.String("labels")) + sshProfileIn["label_ids"] = labelsIdsArr + } + + sshProfile, err := sshProfileSvc.CreateSSHProfile(&sshProfileIn) if err != nil { formatter.PrintFatal("Couldn't create sshProfile", err) } + + LabelAssignNamesForIDs(c, sshProfile) if err = formatter.PrintItem(*sshProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } diff --git a/cmd/template_cmd.go b/cmd/template_cmd.go index 9ef3ccd..bcb497e 100644 --- a/cmd/template_cmd.go +++ b/cmd/template_cmd.go @@ -3,6 +3,7 @@ package cmd import ( "github.com/codegangsta/cli" "github.com/ingrammicro/concerto/api/blueprint" + "github.com/ingrammicro/concerto/api/types" "github.com/ingrammicro/concerto/utils" "github.com/ingrammicro/concerto/utils/format" ) @@ -37,6 +38,19 @@ func TemplateList(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't receive template data", err) } + + filteredResources, err := LabelFiltering(c, templates) + if err != nil { + formatter.PrintFatal("Couldn't list templates filtered by labels", err) + } + if filteredResources != nil { + templates = nil + for _, v := range *filteredResources { + templates = append(templates, v.(types.Template)) + } + } + + LabelAssignNamesForIDs(c, templates) if err = formatter.PrintList(templates); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -53,6 +67,8 @@ func TemplateShow(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't receive template data", err) } + + LabelAssignNamesForIDs(c, template) if err = formatter.PrintItem(*template); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -65,17 +81,30 @@ func TemplateCreate(c *cli.Context) error { templateSvc, formatter := WireUpTemplate(c) checkRequiredFlags(c, []string{"name", "generic_image_id"}, formatter) - // parse json parameter values params, err := utils.FlagConvertParamsJSON(c, []string{"service_list", "configuration_attributes"}) if err != nil { formatter.PrintFatal("Error parsing parameters", err) } - template, err := templateSvc.CreateTemplate(params) + templateIn := map[string]interface{}{ + "name": c.String("name"), + "generic_image_id": c.String("generic_image_id"), + "service_list": (*params)["service_list"], + "configuration_attributes": (*params)["configuration_attributes"], + } + + if c.IsSet("labels") { + labelsIdsArr := LabelResolution(c, c.String("labels")) + templateIn["label_ids"] = labelsIdsArr + } + + template, err := templateSvc.CreateTemplate(&templateIn) if err != nil { formatter.PrintFatal("Couldn't create template", err) } + + LabelAssignNamesForIDs(c, template) if err = formatter.PrintItem(*template); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } diff --git a/labels/subcommands.go b/labels/subcommands.go new file mode 100644 index 0000000..e86c699 --- /dev/null +++ b/labels/subcommands.go @@ -0,0 +1,16 @@ +package labels + +import ( + "github.com/codegangsta/cli" + "github.com/ingrammicro/concerto/cmd" +) + +func SubCommands() []cli.Command { + return []cli.Command{ + { + Name: "list", + Usage: "Lists the current labels existing in the platform for the user", + Action: cmd.LabelList, + }, + } +} diff --git a/main.go b/main.go index bbe80ff..cd170f5 100644 --- a/main.go +++ b/main.go @@ -22,6 +22,7 @@ import ( "github.com/ingrammicro/concerto/converge" "github.com/ingrammicro/concerto/dispatcher" "github.com/ingrammicro/concerto/firewall" + "github.com/ingrammicro/concerto/labels" "github.com/ingrammicro/concerto/network/firewall_profiles" "github.com/ingrammicro/concerto/settings/cloud_accounts" "github.com/ingrammicro/concerto/setup" @@ -247,6 +248,14 @@ var ClientCommands = []cli.Command{ WizardCommands, ), }, + { + Name: "labels", + ShortName: "lbl", + Usage: "Provides information about labels", + Subcommands: append( + labels.SubCommands(), + ), + }, } var appFlags = []cli.Flag{ diff --git a/network/firewall_profiles/subcommands.go b/network/firewall_profiles/subcommands.go index 23ff6e1..e2519a7 100644 --- a/network/firewall_profiles/subcommands.go +++ b/network/firewall_profiles/subcommands.go @@ -11,6 +11,12 @@ func SubCommands() []cli.Command { Name: "list", Usage: "Lists all existing firewall profiles", Action: cmd.FirewallProfileList, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "labels", + Usage: "A list of comma separated label as a query filter", + }, + }, }, { Name: "show", @@ -40,6 +46,10 @@ func SubCommands() []cli.Command { Name: "rules", Usage: "Set of rules of the firewall profile", }, + cli.StringFlag{ + Name: "labels", + Usage: "A list of comma separated label names to be associated with firewall profile", + }, }, }, { @@ -76,5 +86,47 @@ func SubCommands() []cli.Command { }, }, }, + { + Name: "add-label", + Usage: "This action assign a single label from a single labelable resource", + Action: cmd.LabelAdd, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "id", + Usage: "Firewall profile Id", + }, + cli.StringFlag{ + Name: "label", + Usage: "Label name", + }, + cli.StringFlag{ + Name: "resource_type", + Usage: "Resource Type", + Value: "firewall_profile", + Hidden: true, + }, + }, + }, + { + Name: "remove-label", + Usage: "This action de-assign a single label from a single labelable resource", + Action: cmd.LabelRemove, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "id", + Usage: "Firewall profile Id", + }, + cli.StringFlag{ + Name: "label", + Usage: "Label name", + }, + cli.StringFlag{ + Name: "resource_type", + Usage: "Resource Type", + Value: "firewall_profile", + Hidden: true, + }, + }, + }, } } diff --git a/testdata/labels_data.go b/testdata/labels_data.go new file mode 100644 index 0000000..f51724c --- /dev/null +++ b/testdata/labels_data.go @@ -0,0 +1,24 @@ +package testdata + +import ( + "github.com/ingrammicro/concerto/api/types" +) + +// GetLabelData loads test data +func GetLabelData() *[]types.Label { + + testLabels := []types.Label{ + { + ID: "fakeID0", + Name: "fakeName0", + ResourceType: "label", + }, + { + ID: "fakeID1", + Name: "fakeName1", + ResourceType: "label", + }, + } + + return &testLabels +} diff --git a/utils/format/textformatter.go b/utils/format/textformatter.go index 3408de7..67f5680 100644 --- a/utils/format/textformatter.go +++ b/utils/format/textformatter.go @@ -35,14 +35,25 @@ func (f *TextFormatter) PrintItem(item interface{}) error { w := tabwriter.NewWriter(f.output, 15, 1, 3, ' ', 0) for i := 0; i < nf; i++ { - // TODO not the best way to use reflection. Check this later - switch it.Field(i).Type().String() { - case "json.RawMessage": - fmt.Fprintf(w, "%s:\t%s\n", it.Type().Field(i).Tag.Get("header"), it.Field(i).Interface()) - case "*json.RawMessage": - fmt.Fprintf(w, "%s:\t%s\n", it.Type().Field(i).Tag.Get("header"), it.Field(i).Elem()) - default: - fmt.Fprintf(w, "%s:\t%+v\n", it.Type().Field(i).Tag.Get("header"), it.Field(i).Interface()) + // hide fields + bShow := true + showTags := strings.Split(it.Type().Field(i).Tag.Get("show"), ",") + for _, showTag := range showTags { + if showTag == "noshow" { + bShow = false + } + } + + if bShow { + // TODO not the best way to use reflection. Check this later + switch it.Field(i).Type().String() { + case "json.RawMessage": + fmt.Fprintf(w, "%s:\t%s\n", it.Type().Field(i).Tag.Get("header"), it.Field(i).Interface()) + case "*json.RawMessage": + fmt.Fprintf(w, "%s:\t%s\n", it.Type().Field(i).Tag.Get("header"), it.Field(i).Elem()) + default: + fmt.Fprintf(w, "%s:\t%+v\n", it.Type().Field(i).Tag.Get("header"), it.Field(i).Interface()) + } } } fmt.Fprintln(w) diff --git a/utils/utils.go b/utils/utils.go index d4de652..8b8b2a6 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -167,6 +167,7 @@ func CheckRequiredFlags(c *cli.Context, flags []string) { } } +// RandomString generates a random string from lowercase letters and numbers func RandomString(strlen int) string { r := rand.New(rand.NewSource(time.Now().UnixNano())) const chars = "abcdefghijklmnopqrstuvwxyz0123456789" @@ -176,3 +177,44 @@ func RandomString(strlen int) string { } return string(result) } + +// RemoveDuplicates returns the slice removing duplicates if exist +func RemoveDuplicates(elements []string) []string { + encountered := map[string]bool{} + + // Create a map of all unique elements. + for v := range elements { + encountered[elements[v]] = true + } + + // Place all keys from the map into a slice. + result := []string{} + for key := range encountered { + result = append(result, key) + } + return result +} + +// Contains evaluates whether s contains x. +func Contains(s []string, x string) bool { + for _, n := range s { + if x == n { + return true + } + } + return false +} + +// Subset returns true if the first slice is completely contained in the second slice. +// There must be at least the same number of duplicate values in second as there are in first. +func Subset(s1, s2 []string) bool { + if len(s1) > len(s2) { + return false + } + for _, e := range s1 { + if !Contains(s2, e) { + return false + } + } + return true +} From af9b96046837cea6d71a70daf17b452ce2dd4116 Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Thu, 19 Jul 2018 13:52:50 +0200 Subject: [PATCH 02/30] Added test cases (issue #77) --- api/labels/labels_api_mocked.go | 250 +++++++++++++++++++++++++++++--- api/labels/labels_api_test.go | 25 +++- testdata/labels_data.go | 40 +++++ 3 files changed, 292 insertions(+), 23 deletions(-) diff --git a/api/labels/labels_api_mocked.go b/api/labels/labels_api_mocked.go index bf2b615..ec0d5e0 100644 --- a/api/labels/labels_api_mocked.go +++ b/api/labels/labels_api_mocked.go @@ -34,6 +34,30 @@ func GetLabelListMocked(t *testing.T, labelsIn *[]types.Label) *[]types.Label { return &labelsOut } +// GetLabelListMockedWithNamespace test mocked function +func GetLabelListMockedWithNamespace(t *testing.T, labelsIn *[]types.Label) *[]types.Label { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewLabelService(cs) + assert.Nil(err, "Couldn't load label service") + assert.NotNil(ds, "Label service not instanced") + + // to json + dIn, err := json.Marshal(labelsIn) + assert.Nil(err, "Label test data corrupted") + + // call service + cs.On("Get", "/v1/labels").Return(dIn, 200, nil) + labelsOut, err := ds.GetLabelList() + assert.Nil(err, "Error getting labels list") + assert.NotEqual(*labelsIn, labelsOut, "GetLabelList returned labels with Namespaces") + + return &labelsOut +} + // GetLabelListFailErrMocked test mocked function func GetLabelListFailErrMocked(t *testing.T, labelsIn *[]types.Label) *[]types.Label { @@ -199,31 +223,215 @@ func CreateLabelFailStatusMocked(t *testing.T, labelIn *types.Label) *types.Labe return labelOut } -// // CreateLabelFailJSONMocked test mocked function -// func CreateLabelFailJSONMocked(t *testing.T, labelIn *types.Label) *types.Label { +// CreateLabelFailJSONMocked test mocked function +func CreateLabelFailJSONMocked(t *testing.T, labelIn *types.Label) *types.Label { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewLabelService(cs) + assert.Nil(err, "Couldn't load label service") + assert.NotNil(ds, "Label service not instanced") + + // convertMap + mapIn, err := utils.ItemConvertParams(*labelIn) + assert.Nil(err, "Label test data corrupted") + + // wrong json + dIn := []byte{10, 20, 30} + + // call service + cs.On("Post", "/v1/labels/", mapIn).Return(dIn, 200, nil) + labelOut, err := ds.CreateLabel(mapIn) + + assert.NotNil(err, "We are expecting a marshalling error") + assert.Nil(labelOut, "Expecting nil output") + assert.Contains(err.Error(), "invalid character", "Error message should include the string 'invalid character'") + + return labelOut +} + +// AddLabelMocked test mocked function +func AddLabelMocked(t *testing.T, labelIn *types.Label, labeledResourcesOut []types.LabeledResources) []types.LabeledResources { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewLabelService(cs) + assert.Nil(err, "Couldn't load label service") + assert.NotNil(ds, "Label service not instanced") + + // convertMap + mapIn, err := utils.ItemConvertParams(*labelIn) + assert.Nil(err, "Label test data corrupted") + + // to json + dOut, err := json.Marshal(labeledResourcesOut) + assert.Nil(err, "Label test data corrupted") + + // call service + cs.On("Post", fmt.Sprintf("/v1/labels/%s/resources", labelIn.ID), mapIn).Return(dOut, 200, nil) + labeledOut, err := ds.AddLabel(mapIn, labelIn.ID) + assert.Nil(err, "Error creating label") + assert.Equal(labeledOut, labeledResourcesOut, "CreateLabel returned invalid labeled resources") + + return labeledResourcesOut +} + +// AddLabelFailErrMocked test mocked function +func AddLabelFailErrMocked(t *testing.T, labelIn *types.Label, labeledResourcesOut []types.LabeledResources) []types.LabeledResources { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewLabelService(cs) + assert.Nil(err, "Couldn't load label service") + assert.NotNil(ds, "Label service not instanced") + + // convertMap + mapIn, err := utils.ItemConvertParams(*labelIn) + assert.Nil(err, "Label test data corrupted") + + // to json + dOut, err := json.Marshal(labeledResourcesOut) + assert.Nil(err, "Label test data corrupted") + + // call service + cs.On("Post", fmt.Sprintf("/v1/labels/%s/resources", labelIn.ID), mapIn).Return(dOut, 200, fmt.Errorf("Mocked error")) + labeledOut, err := ds.AddLabel(mapIn, labelIn.ID) + assert.NotNil(err, "We are expecting an error") + assert.Nil(labeledOut, "Expecting nil output") + assert.Equal(err.Error(), "Mocked error", "Error should be 'Mocked error'") + + return labeledResourcesOut +} + +// AddLabelFailStatusMocked test mocked function +func AddLabelFailStatusMocked(t *testing.T, labelIn *types.Label, labeledResourcesOut []types.LabeledResources) []types.LabeledResources { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewLabelService(cs) + assert.Nil(err, "Couldn't load label service") + assert.NotNil(ds, "Label service not instanced") + + // convertMap + mapIn, err := utils.ItemConvertParams(*labelIn) + assert.Nil(err, "Label test data corrupted") + + // to json + dOut, err := json.Marshal(labeledResourcesOut) + assert.Nil(err, "Label test data corrupted") + + // call service + cs.On("Post", fmt.Sprintf("/v1/labels/%s/resources", labelIn.ID), mapIn).Return(dOut, 404, nil) + labeledOut, err := ds.AddLabel(mapIn, labelIn.ID) + assert.NotNil(err, "We are expecting an status code error") + assert.Nil(labeledOut, "Expecting nil output") + assert.Contains(err.Error(), "404", "Error should contain http code 404") + + return labeledResourcesOut +} + +// AddLabelFailJSONMocked test mocked function +func AddLabelFailJSONMocked(t *testing.T, labelIn *types.Label, labeledResourcesOut []types.LabeledResources) []types.LabeledResources { + + assert := assert.New(t) -// assert := assert.New(t) + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewLabelService(cs) + assert.Nil(err, "Couldn't load label service") + assert.NotNil(ds, "Label service not instanced") + + // convertMap + mapIn, err := utils.ItemConvertParams(*labelIn) + assert.Nil(err, "Label test data corrupted") -// // wire up -// cs := &utils.MockConcertoService{} -// ds, err := NewLabelService(cs) -// assert.Nil(err, "Couldn't load label service") -// assert.NotNil(ds, "Label service not instanced") + // wrong json + dOut := []byte{10, 20, 30} -// // convertMap -// mapIn, err := utils.ItemConvertParams(*labelIn) -// assert.Nil(err, "Label test data corrupted") + // call service + cs.On("Post", fmt.Sprintf("/v1/labels/%s/resources", labelIn.ID), mapIn).Return(dOut, 200, nil) + labeledOut, err := ds.AddLabel(mapIn, labelIn.ID) + assert.NotNil(err, "We are expecting a marshalling error") + assert.Nil(labeledOut, "Expecting nil output") + assert.Contains(err.Error(), "invalid character", "Error message should include the string 'invalid character'") -// // wrong json -// dIn := []byte{10, 20, 30} + return labeledResourcesOut +} -// // call service -// cs.On("Post", "/v1/cloud/servers/", mapIn).Return(dIn, 200, nil) -// labelOut, err := ds.CreateLabel(mapIn) +// RemoveLabelMocked test mocked function +func RemoveLabelMocked(t *testing.T, labelIn *types.Label) { -// assert.NotNil(err, "We are expecting a marshalling error") -// assert.Nil(labelOut, "Expecting nil output") -// assert.Contains(err.Error(), "invalid character", "Error message should include the string 'invalid character'") + assert := assert.New(t) -// return labelOut -// } + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewLabelService(cs) + assert.Nil(err, "Couldn't load label service") + assert.NotNil(ds, "Label service not instanced") + + // to json + dIn, err := json.Marshal(labelIn) + assert.Nil(err, "Label test data corrupted") + + // call service + resourceID := "5b5074735f7c880ad9c6bbce" + cs.On("Delete", fmt.Sprintf("v1/labels/%s/resources/%s/%s", labelIn.ID, labelIn.ResourceType, resourceID)).Return(dIn, 204, nil) + err = ds.RemoveLabel(labelIn.ID, labelIn.ResourceType, resourceID) + assert.Nil(err, "Error removing label") +} + +// RemoveLabelFailErrMocked test mocked function +func RemoveLabelFailErrMocked(t *testing.T, labelIn *types.Label) { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewLabelService(cs) + assert.Nil(err, "Couldn't load label service") + assert.NotNil(ds, "Label service not instanced") + + // to json + dIn, err := json.Marshal(labelIn) + assert.Nil(err, "Label test data corrupted") + + // call service + resourceID := "5b5074735f7c880ad9c6bbce" + cs.On("Delete", fmt.Sprintf("v1/labels/%s/resources/%s/%s", labelIn.ID, labelIn.ResourceType, resourceID)).Return(dIn, 204, fmt.Errorf("Mocked error")) + err = ds.RemoveLabel(labelIn.ID, labelIn.ResourceType, resourceID) + + assert.NotNil(err, "We are expecting an error") + assert.Equal(err.Error(), "Mocked error", "Error should be 'Mocked error'") +} + +// RemoveLabelFailStatusMocked test mocked function +func RemoveLabelFailStatusMocked(t *testing.T, labelIn *types.Label) { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewLabelService(cs) + assert.Nil(err, "Couldn't load label service") + assert.NotNil(ds, "Label service not instanced") + + // to json + dIn, err := json.Marshal(labelIn) + assert.Nil(err, "Label test data corrupted") + + // call service + resourceID := "5b5074735f7c880ad9c6bbce" + cs.On("Delete", fmt.Sprintf("v1/labels/%s/resources/%s/%s", labelIn.ID, labelIn.ResourceType, resourceID)).Return(dIn, 404, nil) + err = ds.RemoveLabel(labelIn.ID, labelIn.ResourceType, resourceID) + + assert.NotNil(err, "We are expecting an status code error") + assert.Contains(err.Error(), "404", "Error should contain http code 404") +} diff --git a/api/labels/labels_api_test.go b/api/labels/labels_api_test.go index 8999aa9..f14e715 100644 --- a/api/labels/labels_api_test.go +++ b/api/labels/labels_api_test.go @@ -14,9 +14,10 @@ func TestNewLabelServiceNil(t *testing.T) { assert.NotNil(err, "Uninitialized service should return error") } -func TestGetCloudProviderList(t *testing.T) { +func TestGetLabelsList(t *testing.T) { labelsIn := testdata.GetLabelData() GetLabelListMocked(t, labelsIn) + GetLabelListMockedWithNamespace(t, testdata.GetLabelWithNamespaceData()) GetLabelListFailErrMocked(t, labelsIn) GetLabelListFailStatusMocked(t, labelsIn) GetLabelListFailJSONMocked(t, labelsIn) @@ -28,6 +29,26 @@ func TestCreateLabel(t *testing.T) { CreateLabelMocked(t, &labelIn) CreateLabelFailErrMocked(t, &labelIn) CreateLabelFailStatusMocked(t, &labelIn) - //CreateLabelFailJSONMocked(t, &labelIn) + CreateLabelFailJSONMocked(t, &labelIn) + } +} + +func TestAddLabel(t *testing.T) { + labelsIn := testdata.GetLabelData() + labeledResourcesOut := testdata.GetLabeledResourcesData() + for _, labelIn := range *labelsIn { + AddLabelMocked(t, &labelIn, *labeledResourcesOut) + AddLabelFailErrMocked(t, &labelIn, *labeledResourcesOut) + AddLabelFailStatusMocked(t, &labelIn, *labeledResourcesOut) + AddLabelFailJSONMocked(t, &labelIn, *labeledResourcesOut) + } +} + +func TestRemoveLabel(t *testing.T) { + labelsIn := testdata.GetLabelData() + for _, labelIn := range *labelsIn { + RemoveLabelMocked(t, &labelIn) + RemoveLabelFailErrMocked(t, &labelIn) + RemoveLabelFailStatusMocked(t, &labelIn) } } diff --git a/testdata/labels_data.go b/testdata/labels_data.go index f51724c..0b5af93 100644 --- a/testdata/labels_data.go +++ b/testdata/labels_data.go @@ -22,3 +22,43 @@ func GetLabelData() *[]types.Label { return &testLabels } + +// GetLabelWithNamespaceData loads test data +func GetLabelWithNamespaceData() *[]types.Label { + + testLabels := []types.Label{ + { + ID: "fakeID0", + Name: "fakeName0", + ResourceType: "label", + Namespace: "fakeNamespace0", + Value: "fakeValue0", + }, + { + ID: "fakeID1", + Name: "fakeName1", + ResourceType: "label", + Namespace: "fakeNamespace1", + Value: "fakeValue1", + }, + } + + return &testLabels +} + +// GetLabeledResourcesData loads test data +func GetLabeledResourcesData() *[]types.LabeledResources { + + testLabeledResources := []types.LabeledResources{ + { + ID: "fakeID0", + ResourceType: "server", + }, + { + ID: "fakeID1", + ResourceType: "template", + }, + } + + return &testLabeledResources +} From 5a442b7f29b80734bdf7388eb6989d871d0241a3 Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Fri, 20 Jul 2018 12:57:54 +0200 Subject: [PATCH 03/30] Populated label names (issue #77) --- cmd/firewall_profiles_cmd.go | 2 ++ cmd/scripts_cmd.go | 2 ++ cmd/servers_cmd.go | 10 ++++++++++ cmd/ssh_profiles_cmd.go | 2 ++ cmd/template_cmd.go | 2 ++ 5 files changed, 18 insertions(+) diff --git a/cmd/firewall_profiles_cmd.go b/cmd/firewall_profiles_cmd.go index 0652033..569bbea 100644 --- a/cmd/firewall_profiles_cmd.go +++ b/cmd/firewall_profiles_cmd.go @@ -124,6 +124,8 @@ func FirewallProfileUpdate(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't update firewallProfile", err) } + + LabelAssignNamesForIDs(c, firewallProfile) if err = formatter.PrintItem(*firewallProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } diff --git a/cmd/scripts_cmd.go b/cmd/scripts_cmd.go index 763f6b9..83f610a 100644 --- a/cmd/scripts_cmd.go +++ b/cmd/scripts_cmd.go @@ -118,6 +118,8 @@ func ScriptUpdate(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't update script", err) } + + LabelAssignNamesForIDs(c, script) if err = formatter.PrintItem(*script); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } diff --git a/cmd/servers_cmd.go b/cmd/servers_cmd.go index b6fa420..8bd30d2 100644 --- a/cmd/servers_cmd.go +++ b/cmd/servers_cmd.go @@ -117,6 +117,8 @@ func ServerUpdate(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't update server", err) } + + LabelAssignNamesForIDs(c, server) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -133,6 +135,8 @@ func ServerBoot(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't boot server", err) } + + LabelAssignNamesForIDs(c, server) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -149,6 +153,8 @@ func ServerReboot(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't reboot server", err) } + + LabelAssignNamesForIDs(c, server) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -165,6 +171,8 @@ func ServerShutdown(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't shutdown server", err) } + + LabelAssignNamesForIDs(c, server) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -181,6 +189,8 @@ func ServerOverride(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't override server", err) } + + LabelAssignNamesForIDs(c, server) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } diff --git a/cmd/ssh_profiles_cmd.go b/cmd/ssh_profiles_cmd.go index 4497e6c..8d01bba 100644 --- a/cmd/ssh_profiles_cmd.go +++ b/cmd/ssh_profiles_cmd.go @@ -115,6 +115,8 @@ func SSHProfileUpdate(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't update sshProfile", err) } + + LabelAssignNamesForIDs(c, sshProfile) if err = formatter.PrintItem(*sshProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } diff --git a/cmd/template_cmd.go b/cmd/template_cmd.go index bcb497e..ab181e5 100644 --- a/cmd/template_cmd.go +++ b/cmd/template_cmd.go @@ -128,6 +128,8 @@ func TemplateUpdate(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't update template", err) } + + LabelAssignNamesForIDs(c, template) if err = formatter.PrintItem(*template); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } From 1f2b8c23db2c803ed4519e5bdf927e7a2bc0ad35 Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Fri, 20 Jul 2018 14:06:17 +0200 Subject: [PATCH 04/30] Updated README (issue #77) - Removed 'workspaces' - Added 'labels' --- README.md | 258 +++++++++++++++------------- docs/images/commissioned-server.png | Bin 59825 -> 58983 bytes docs/images/server-bootstraping.png | Bin 33277 -> 26587 bytes docs/images/server-operational.png | Bin 32024 -> 28172 bytes 4 files changed, 139 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index f652d40..d9fdd43 100644 --- a/README.md +++ b/README.md @@ -49,11 +49,11 @@ Once your account has been provisioned, we recommend you to follow the configura ## Manual Setup -Use IMCO's Web UI to navigate the menus to `Settings` > `User Details` and scroll down until you find the `New API Key` button. +Use IMCO's Web UI to navigate the menus to `Settings` > `User Details` and scroll down until you find the `API Key` button. API Key -Pressing `New API Key` will download a compressed file that contains the necessary files to authenticate with IMCO API and manage your infrastructure. `Keep it safe`. +Pressing `Create and download a new API key` will download a compressed file that contains the necessary files to authenticate with IMCO API and manage your infrastructure. `Keep it safe`. Extract the contents with your zip compressor of choice and continue using the setup guide for your O.S. @@ -131,15 +131,16 @@ USAGE: concerto [global options] command [command options] [arguments...] VERSION: - 0.6.1 + 0.7.0 AUTHOR: Concerto Contributors COMMANDS: blueprint, bl Manages blueprint commands for scripts, services and templates - cloud, clo Manages cloud related commands for workspaces, servers, generic images, ssh profiles, cloud providers, server plans and Saas providers + cloud, clo Manages cloud related commands for servers, generic images, ssh profiles, cloud providers, server plans and Saas providers events, ev Events allow the user to track their actions and the state of their servers + labels, lbl Provides information about labels network, net Manages network related commands for firewall profiles settings, set Provides settings for cloud accounts setup, se Configures and setups concerto cli enviroment @@ -147,12 +148,17 @@ COMMANDS: ... ``` -To test that certificates are valid, and that we can communicate with IMCO server, obtain the list of workspaces at your IMCO account using this command +To test that certificates are valid, and that we can communicate with IMCO server, obtain the list of cloud providers at your IMCO account using this command ```bash -$ concerto cloud workspaces list -ID NAME DEFAULT SSH_PROFILE_ID FIREWALL_PROFILE_ID -5aabb7521de0240abb00000e default true 5aabb7521de0240abb00000d 5aabb7521de0240abb00000c +$ concerto cloud cloud_providers list +ID NAME +5aabb7511de0240abb000001 AWS +5aabb7511de0240abb000002 Mock +5aabb7511de0240abb000003 DigitalOcean +5aabb7511de0240abb000004 Microsoft Azure ARM +5aabb7511de0240abb000005 Microsoft Azure +5aba04be425b5d0c16000000 VCloud ``` ## Environment variables @@ -184,6 +190,8 @@ If you got an error executing IMCO CLI: We include the most common use cases here. If you feel there is a missing a use case here, open an github issue . +From release 0.7.0 the resources can be organized using labels, a many-to-many relationship between labels and resources, based on User criteria and needs ('workspaces' are not available anymore) + ## Wizard The Wizard command for IMCO CLI is the command line version of our `Quick add server` in the IMCO's Web UI. @@ -281,46 +289,32 @@ FLAVOUR_REQUIREMENTS: GENERIC_IMAGE_ID: ``` -We have a new server template and a workspace with a commissioned server in IMCO. +We have a new server template with a commissioned server in IMCO. Server Commissioned -From the command line, get the new workspace, and then our commissioned server ID. - -```bash -$ concerto cloud workspaces list -ID NAME DEFAULT SSH_PROFILE_ID FIREWALL_PROFILE_ID -5aabb7521de0240abb00000e default true 5aabb7521de0240abb00000d 5aabb7521de0240abb00000c -5b0ea6377906e900fab96797 Wordpress_workspace false 5aabb7521de0240abb00000d 5b0ea6377906e900fab96795 -``` - -```bash -$ concerto cloud workspaces list_workspace_servers --workspace_id 5b0ea6377906e900fab96797 -ID NAME FQDN STATE PUBLIC_IP WORKSPACE_ID TEMPLATE_ID SERVER_PLAN_ID SSH_PROFILE_ID -5b0ea6377906e900fab96798 wpnode1 sf98aa2c61069a1b.centralus.cloudapp.azure.com inactive 104.43.245.138 5b0ea6377906e900fab96797 5b0ea6377906e900fab96792 5aac0c05348f190b3e0011c2 5aabb7521de0240abb00000d -``` - Our server's ID is `5b0ea6377906e900fab96798`. We can now use `concerto cloud servers` subcommands to manage the server. Lets bring wordpress up: ```bash $ concerto cloud servers boot --id 5b0ea6377906e900fab96798 -ID: 5b0ea6377906e900fab96798 -NAME: wpnode1 -FQDN: sf98aa2c61069a1b.centralus.cloudapp.azure.com -STATE: booting -PUBLIC_IP: 104.43.245.138 -WORKSPACE_ID: 5b0ea6377906e900fab96797 -TEMPLATE_ID: 5b0ea6377906e900fab96792 -SERVER_PLAN_ID: 5aac0c05348f190b3e0011c2 -CLOUD_ACCOUNT_ID: 5aabb7531de0240abb000024 -SSH_PROFILE_ID: 5aabb7521de0240abb00000d +ID: 5b0ea6377906e900fab96798 +NAME: wpnode1 +FQDN: sf98aa2c61069a1b.centralus.cloudapp.azure.com +STATE: booting +PUBLIC_IP: 104.43.245.138 +TEMPLATE_ID: 5b0ea6377906e900fab96792 +SERVER_PLAN_ID: 5aac0c05348f190b3e0011c2 +CLOUD_ACCOUNT_ID: 5aabb7531de0240abb000024 +SSH_PROFILE_ID: 5aabb7521de0240abb00000e +FIREWALL_PROFILE_ID: 5b50a4c75f7c880ad9c6bbfb +RESOURCE_TYPE: server +LABELS: [Wordpress] ``` Server status: `Bootstraping` Server Bootstraping - Server status: `Operational` Server Operational @@ -359,6 +353,7 @@ ID NAME 5aabb7551de0240abb000068 Red Hat Enterprise Linux 7.3 x86_64 5aabb7551de0240abb000069 CentOS 7.4 x86_64 5aabb7551de0240abb00006a Debian 9 x86_64 +5b2a331ee09b740b5ee72f24 Ubuntu 18.04 Bionic Beaver x86_64 ``` Take note of Ubuntu 16.04 ID, `5aabb7551de0240abb000065`. @@ -376,29 +371,22 @@ ID NAME DESCRIPTION Joomla curated cookbooks creates a local mysql database. We only have to tell our cookbook that we should override the `joomla.db.hostname` to `127.0.0.1`. Execute the following command to create the Joomla template. ```bash -$ concerto blueprint templates create --name joomla-tmplt --generic_image_id 5aabb7551de0240abb000065 --service_list '["joomla"]' --configuration_attributes '{"joomla":{"db":{"hostname":"127.0.0.1"}}}' -ID: 5b0ebc6e7906e900fab967a3 +$ concerto blueprint templates create --name joomla-tmplt --generic_image_id 5aabb7551de0240abb000065 --service_list '["joomla"]' --configuration_attributes '{"joomla":{"db":{"hostname":"127.0.0.1"}}}' --labels Joomla,mysite.com +ID: 5b5192b15f7c880ad9c6bc12 NAME: joomla-tmplt GENERIC IMAGE ID: 5aabb7551de0240abb000065 SERVICE LIST: [joomla] CONFIGURATION ATTRIBUTES: {"joomla":{"db":{"hostname":"127.0.0.1"}}} +RESOURCE_TYPE: template +LABELS: [mysite.com Joomla] ``` #### Instantiate a server -Now that we have our server blueprint defined, let's start one. Servers in IMCO need to know the workspace that define their runtime infrastructure environment, the server plan for the cloud provider, and the template used to build the instance. +Now that we have our server blueprint defined, let's start one. Servers in IMCO need to know the server plan for the cloud provider, and the template used to build the instance. As we did in the Wizard use case, we can find the missing data using these commands: -##### Find the workspace - -```bash -$ concerto cloud workspaces list -ID NAME DEFAULT SSH_PROFILE_ID FIREWALL_PROFILE_ID -5aabb7521de0240abb00000e default true 5aabb7521de0240abb00000d 5aabb7521de0240abb00000c -5b0ea6377906e900fab96797 Wordpress_workspace false 5aabb7521de0240abb00000d 5b0ea6377906e900fab96795 -``` - ##### Find cloud provider server plan ```bash @@ -451,11 +439,11 @@ We already know our template ID, but in case you want to make sure ```bash $ concerto blueprint templates list -ID NAME GENERIC IMAGE ID -5afd5b4c42d90d09f00000aa windows 2016 5aabb7551de0240abb000067 -5b067fe8f585000b80809a8e ubuntu 16.04 5aabb7551de0240abb000065 -5b0ea6377906e900fab96792 Wordpress_template 5aabb7551de0240abb000064 -5b0ebc6e7906e900fab967a3 joomla-tmplt 5aabb7551de0240abb000065 +ID NAME GENERIC IMAGE ID LABELS +5afd5b4c42d90d09f00000aa windows 2016 5aabb7551de0240abb000067 [] +5b067fe8f585000b80809a8e ubuntu 16.04 5aabb7551de0240abb000065 [] +5b0ea6377906e900fab96792 Wordpress_template 5aabb7551de0240abb000064 [Wordpress] +5b5192b15f7c880ad9c6bc12 joomla-tmplt 5aabb7551de0240abb000065 [mysite.com Joomla] ``` ##### Find Location ID @@ -473,7 +461,7 @@ ID NAME ##### Find Cloud Account ID -It's necessary to retrive the adequeate Cloud Account ID for `Microsoft Azure` Cloud Provider, in our case `5aabb7511de0240abb000005`: +It's necessary to retrieve the adequate Cloud Account ID for `Microsoft Azure` Cloud Provider, in our case `5aabb7511de0240abb000005`: ```bash $ concerto settings cloud_accounts list @@ -489,57 +477,87 @@ ID NAME CLOUD_PROVID 5aba066c425b5d0c64000002 VMWare-Routed-cloud_account-name 5aba04be425b5d0c16000000 VCloud ``` +##### Find SSH Profile ID + +It's necessary to retrieve the adequate SSH Profile ID. It can be created using CLI commands or IMCO UI. + +```bash +$ concerto cloud ssh_profiles list +ID NAME PUBLIC_KEY LABELS +5aabb7521de0240abb00000d default ssh-rsa AAAAB3NzaC1yc[...] [] +5aabb7521de0240abb00000e Joomla SSH ssh-rsa AAAABBfD4Klmn[...] [mysite.com Joomla] +[...] +``` + +##### Find Firewall Profile ID + +It's necessary to retrieve the adequate Firewall Profile ID. It can be created using CLI commands or IMCO UI. + +```bash +$ concerto network firewall_profiles list +ID NAME DESCRIPTION DEFAULT LABELS +5aabb7521de0240abb00000c Default firewall Firewall profile created by the platfom for your use true [] +5b519da77fb2480b0831d9d2 Joomla Firewall Firewall profile created for joomla management false [mysite.com Joomla] +[...] +``` + ##### Create our Joomla Server ```bash -$ concerto cloud servers create --name joomla-node1 --workspace_id 5aabb7521de0240abb00000e --template_id 5b0ebc6e7906e900fab967a3 --server_plan_id 5aac0c04348f190b3e001186 --cloud_account_id 5aabb7531de0240abb000024 -ID: 5b0ebe297906e900fab967a7 -NAME: joomla-node1 +$ concerto cloud servers create --name joomla-node1 --template_id 5b5192b15f7c880ad9c6bc12 --server_plan_id 5aac0c04348f190b3e001186 --cloud_account_id 5aabb7531de0240abb000024 --ssh_profile_id 5aabb7521de0240abb00000e --firewall_profile_id 5b519da77fb2480b0831d9d2 --labels Joomla,mysite.com +ID: 5b5193675f7c880ad9c6bc16 +NAME: joomla-node1 FQDN: -STATE: commissioning +STATE: commissioning PUBLIC_IP: -WORKSPACE_ID: 5aabb7521de0240abb00000e -TEMPLATE_ID: 5b0ebc6e7906e900fab967a3 -SERVER_PLAN_ID: 5aac0c04348f190b3e001186 -CLOUD_ACCOUNT_ID: 5aabb7531de0240abb000024 -SSH_PROFILE_ID: 5aabb7521de0240abb00000d +TEMPLATE_ID: 5b5192b15f7c880ad9c6bc12 +SERVER_PLAN_ID: 5aac0c04348f190b3e001186 +CLOUD_ACCOUNT_ID: 5aabb7531de0240abb000024 +SSH_PROFILE_ID: 5aabb7521de0240abb00000e +FIREWALL_PROFILE_ID: 5b519da77fb2480b0831d9d2 +RESOURCE_TYPE: server +LABELS: [mysite.com Joomla] ``` And finally boot it ```bash -$ concerto cloud servers boot --id 5b0ebe297906e900fab967a7 -ID: 5b0ebe297906e900fab967a7 -NAME: joomla-node1 +$ concerto cloud servers boot --id 5b5193675f7c880ad9c6bc16 +ID: 5b5193675f7c880ad9c6bc16 +NAME: joomla-node1 FQDN: -STATE: booting +STATE: booting PUBLIC_IP: -WORKSPACE_ID: 5aabb7521de0240abb00000e -TEMPLATE_ID: 5b0ebc6e7906e900fab967a3 -SERVER_PLAN_ID: 5aac0c04348f190b3e001186 -CLOUD_ACCOUNT_ID: 5aabb7531de0240abb000024 -SSH_PROFILE_ID: 5aabb7521de0240abb00000d +TEMPLATE_ID: 5b5192b15f7c880ad9c6bc12 +SERVER_PLAN_ID: 5aac0c04348f190b3e001186 +CLOUD_ACCOUNT_ID: 5aabb7531de0240abb000024 +SSH_PROFILE_ID: 5aabb7521de0240abb00000e +FIREWALL_PROFILE_ID: 5b519da77fb2480b0831d9d2 +RESOURCE_TYPE: server +LABELS: [mysite.com Joomla] ``` You can retrieve the current status of the server and see how it transitions along different statuses (booting, bootstrapping, operational). Then, after a brief amount of time the final status is reached: ```bash -$ concerto cloud servers show --id 5b0ebe297906e900fab967a7 -ID: 5b0ebe297906e900fab967a7 -NAME: joomla-node1 -FQDN: s22e6c216adaec08.centralus.cloudapp.azure.com -STATE: operational -PUBLIC_IP: 104.43.242.14 -WORKSPACE_ID: 5aabb7521de0240abb00000e -TEMPLATE_ID: 5b0ebc6e7906e900fab967a3 -SERVER_PLAN_ID: 5aac0c04348f190b3e001186 -CLOUD_ACCOUNT_ID: 5aabb7531de0240abb000024 -SSH_PROFILE_ID: 5aabb7521de0240abb00000d +$ concerto cloud servers show --id 5b5193675f7c880ad9c6bc16 +ID: 5b5193675f7c880ad9c6bc16 +NAME: joomla-node1 +FQDN: s6ef3f68038ec9e8.centralus.cloudapp.azure.com +STATE: operational +PUBLIC_IP: 23.99.252.146 +TEMPLATE_ID: 5b5192b15f7c880ad9c6bc12 +SERVER_PLAN_ID: 5aac0c04348f190b3e001186 +CLOUD_ACCOUNT_ID: 5aabb7531de0240abb000024 +SSH_PROFILE_ID: 5aabb7521de0240abb00000e +FIREWALL_PROFILE_ID: 5b519da77fb2480b0831d9d2 +RESOURCE_TYPE: server +LABELS: [mysite.com Joomla] ``` ## Firewall Management -IMCO CLI's `network` command lets you manage a network settings at the workspace scope. +IMCO CLI's `network` command lets you manage a network settings at the server scope. As we have did before, execute this command with no futher commands to get usage information: @@ -559,36 +577,28 @@ As you can see, you can manage firewall from IMCO CLI. ### Firewall Update Case -Workspaces in IMCO are always associated with a firewall profile. By default ports 443 and 80 are open to fit most web environments, but if you are not using those ports but some others. We would need to close HTTP and HTTPS ports and open LDAP and LDAPS instead. - -The first thing we will need is our workspace's related firewall identifier. - -```bash -$ concerto cloud workspaces list -ID NAME DEFAULT SSH_PROFILE_ID FIREWALL_PROFILE_ID -5aabb7521de0240abb00000e default true 5aabb7521de0240abb00000d 5aabb7521de0240abb00000c -5b0ea6377906e900fab96797 Wordpress_workspace false 5aabb7521de0240abb00000d 5b0ea6377906e900fab96795 -5b0ec2e594771f0b76361dd9 My New Workspace false 5aabb7521de0240abb00000d 5b0ec2c994771f0b76361dd6 -``` +Servers in IMCO are always associated with a firewall profile. By default ports 443 and 80 are open to fit most web environments, but if you are not using those ports but some others. We would need to close HTTP and HTTPS ports and open LDAP and LDAPS instead. -We have our LDAP servers running under `My New Workspace`. If you are unsure about in which workspace are your servers running, list the servers in the workspace +The first thing we will need is our servers's related firewall identifier. In this they can be found filtering by label assigned 'LDAP': ```bash -concerto cloud workspaces list_workspace_servers --workspace_id 5b0ec2e594771f0b76361dd9 -ID NAME FQDN STATE PUBLIC_IP WORKSPACE_ID TEMPLATE_ID SERVER_PLAN_ID SSH_PROFILE_ID -5b0ec38494771f0b76361ddb openldap-1 inactive 5b0ec2e594771f0b76361dd9 5b067fe8f585000b80809a8e 5aabb76fe499780a0000059a 5aabb7521de0240abb00000d -5b0ec3a294771f0b76361dde openldap-2 scd9ee0721949943.northcentralus.cloudapp.azure.com operational 23.101.166.77 5b0ec2e594771f0b76361dd9 5b067fe8f585000b80809a8e 5aac0c0e348f190b3e001433 5aabb7521de0240abb00000d +$ concerto cloud servers list --labels LDAP +ID NAME FQDN STATE PUBLIC_IP TEMPLATE_ID SERVER_PLAN_ID CLOUD_ACCOUNT_ID SSH_PROFILE_ID FIREWALL_PROFILE_ID LABELS +5b51a9dc7fb2480b0831d9eb openldap-1 inactive 5afd5b4c42d90d09f00000aa 5aac0c0e348f190b3e001432 5aabb7531de0240abb000024 5b51a9617fb2480b0831d9e9 5b51a9377fb2480b0831d9e6 [LDAP] +5b51a9ff7fb2480b0831d9ee openldap-2 sca9229d77b151d4.northcentralus.cloudapp.azure.com operational 23.100.76.238 5afd5b4c42d90d09f00000aa 5aac0c0e348f190b3e001432 5aabb7531de0240abb000024 5b51a9617fb2480b0831d9e9 5b51a9377fb2480b0831d9e6 [LDAP] ``` Now that we have the firewall profile ID, list it's contents ```bash -$ concerto network firewall_profiles show --id 5b0ec2c994771f0b76361dd6 -ID: 5b0ec2c994771f0b76361dd6 -NAME: My New Firewall Profile -DESCRIPTION: -DEFAULT: false -RULES: [{Protocol:tcp MinPort:22 MaxPort:22 CidrIp:any} {Protocol:tcp MinPort:5985 MaxPort:5985 CidrIp:any} {Protocol:tcp MinPort:3389 MaxPort:3389 CidrIp:any} {Protocol:tcp MinPort:10050 MaxPort:10050 CidrIp:any} {Protocol:tcp MinPort:443 MaxPort:443 CidrIp:any} {Protocol:tcp MinPort:80 MaxPort:80 CidrIp:any}] +$ concerto network firewall_profiles show --id 5b51a9377fb2480b0831d9e6 +ID: 5b51a9377fb2480b0831d9e6 +NAME: Firewall LDAP +DESCRIPTION: LDAP Services firewall +DEFAULT: false +RULES: [{Protocol:tcp MinPort:22 MaxPort:22 CidrIP:any} {Protocol:tcp MinPort:5985 MaxPort:5985 CidrIP:any} {Protocol:tcp MinPort:3389 MaxPort:3389 CidrIP:any} {Protocol:tcp MinPort:10050 MaxPort:10050 CidrIP:any} {Protocol:tcp MinPort:443 MaxPort:443 CidrIP:any} {Protocol:tcp MinPort:80 MaxPort:80 CidrIP:any}] +RESOURCE_TYPE: firewall_profile +LABELS: [LDAP] ``` The first four values are ports that IMCO may use to keep the desired state of the machine, and that will always be accessed using certificates. @@ -596,12 +606,14 @@ The first four values are ports that IMCO may use to keep the desired state of t When updating, we tell IMCO a new set of rules. Execute the following command to open 389 and 686 to anyone. ```bash -$ concerto network firewall_profiles update --id 5b0ec2c994771f0b76361dd6 --rules '[{"ip_protocol":"tcp", "min_port":389, "max_port":389, "source":"0.0.0.0/0"}, {"ip_protocol":"tcp", "min_port":636, "max_port":636, "source":"0.0.0.0/0"}]' -ID: 5b0ec2c994771f0b76361dd6 -NAME: My New Firewall Profile -DESCRIPTION: -DEFAULT: false -RULES: [{Protocol:tcp MinPort:22 MaxPort:22 CidrIp:any} {Protocol:tcp MinPort:5985 MaxPort:5985 CidrIp:any} {Protocol:tcp MinPort:3389 MaxPort:3389 CidrIp:any} {Protocol:tcp MinPort:10050 MaxPort:10050 CidrIp:any} {Protocol:tcp MinPort:389 MaxPort:389 CidrIp:any} {Protocol:tcp MinPort:636 MaxPort:636 CidrIp:any}] +$ concerto network firewall_profiles update --id 5b51a9377fb2480b0831d9e6 --rules '[{"ip_protocol":"tcp", "min_port":389, "max_port":389, "source":"0.0.0.0/0"}, {"ip_protocol":"tcp", "min_port":636, "max_port":636, "source":"0.0.0.0/0"}]' +ID: 5b51a9377fb2480b0831d9e6 +NAME: Firewall LDAP +DESCRIPTION: LDAP Services firewall +DEFAULT: false +RULES: [{Protocol:tcp MinPort:22 MaxPort:22 CidrIP:any} {Protocol:tcp MinPort:5985 MaxPort:5985 CidrIP:any} {Protocol:tcp MinPort:3389 MaxPort:3389 CidrIP:any} {Protocol:tcp MinPort:10050 MaxPort:10050 CidrIP:any} {Protocol:tcp MinPort:389 MaxPort:389 CidrIP:any} {Protocol:tcp MinPort:636 MaxPort:636 CidrIP:any}] +RESOURCE_TYPE: firewall_profile +LABELS: [LDAP] ``` Firewall update returns the complete set of rules. As you can see, now LDAP and LDAPS ports are open. @@ -617,45 +629,53 @@ Let's pretend there is an existing Joomla blueprint, and that we want to update This is the Joomla blueprint that we created in a previous use case. ```bash -$ concerto blueprint templates show --id 5b0ebc6e7906e900fab967a3 -ID: 5b0ebc6e7906e900fab967a3 +$ concerto blueprint templates show --id 5b5192b15f7c880ad9c6bc12 +ID: 5b5192b15f7c880ad9c6bc12 NAME: joomla-tmplt GENERIC IMAGE ID: 5aabb7551de0240abb000065 SERVICE LIST: [joomla] CONFIGURATION ATTRIBUTES: {"joomla":{"db":{"hostname":"127.0.0.1"}}} +RESOURCE_TYPE: template +LABELS: [mysite.com Joomla] ``` Beware of adding previous services or configuration attributes. Update will replace existing items with the ones provided. If we don't want to lose the `joomla.db.hostname` attribute, add it to our configuretion attributes parameter: ```bash -$ concerto blueprint templates update --id 5b0ebc6e7906e900fab967a3 --configuration_attributes '{"joomla":{"db":{"hostname":"127.0.0.1", "password":"$afeP4sSw0rd"}}}' -ID: 5b0ebc6e7906e900fab967a3 +$ concerto blueprint templates update --id 5b5192b15f7c880ad9c6bc12 --configuration_attributes '{"joomla":{"db":{"hostname":"127.0.0.1", "password":"$afeP4sSw0rd"}}}' +ID: 5b5192b15f7c880ad9c6bc12 NAME: joomla-tmplt GENERIC IMAGE ID: 5aabb7551de0240abb000065 SERVICE LIST: [joomla] CONFIGURATION ATTRIBUTES: {"joomla":{"db":{"hostname":"127.0.0.1","password":"$afeP4sSw0rd"}}} +RESOURCE_TYPE: template +LABELS: [mysite.com Joomla] ``` As you can see, non specified parameters, like name and service list, remain unchanged. Let's now change the service list, adding a two cookbooks. ```bash -$ concerto blueprint templates update --id 5b0ebc6e7906e900fab967a3 --service_list '["joomla","python@1.4.6","polipo"]' -ID: 5b0ebc6e7906e900fab967a3 +$ concerto blueprint templates update --id 5b5192b15f7c880ad9c6bc12 --service_list '["joomla","python@1.4.6","polipo"]' +ID: 5b5192b15f7c880ad9c6bc12 NAME: joomla-tmplt GENERIC IMAGE ID: 5aabb7551de0240abb000065 SERVICE LIST: [joomla python@1.4.6 polipo] CONFIGURATION ATTRIBUTES: {"joomla":{"db":{"hostname":"127.0.0.1","password":"$afeP4sSw0rd"}}} +RESOURCE_TYPE: template +LABELS: [mysite.com Joomla] ``` Of course, we can change service list and configuration attributes in one command. ```bash -$ concerto blueprint templates update --id 5b0ebc6e7906e900fab967a3 --configuration_attributes '{"joomla":{"db":{"hostname":"127.0.0.1", "password":"$afeP4sSw0rd"}}}' --service_list '["joomla","python@1.4.6","polipo"]' -ID: 5b0ebc6e7906e900fab967a3 +$ concerto blueprint templates update --id 5b5192b15f7c880ad9c6bc12 --configuration_attributes '{"joomla":{"db":{"hostname":"127.0.0.1", "password":"$afeP4sSw0rd"}}}' --service_list '["joomla","python@1.4.6","polipo"]' +ID: 5b5192b15f7c880ad9c6bc12 NAME: joomla-tmplt GENERIC IMAGE ID: 5aabb7551de0240abb000065 SERVICE LIST: [joomla python@1.4.6 polipo] CONFIGURATION ATTRIBUTES: {"joomla":{"db":{"hostname":"127.0.0.1","password":"$afeP4sSw0rd"}}} +RESOURCE_TYPE: template +LABELS: [mysite.com Joomla] ``` ## Contribute diff --git a/docs/images/commissioned-server.png b/docs/images/commissioned-server.png index e2a97303f99735a8ce66af0eb1a7e55152c49319..667ab9fa0d336c4693295855d034904fc202e66e 100644 GIT binary patch literal 58983 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 literal 59825 zcmYhh18^qI_Xe8X*xA^&ZES4Y*2cDN+j?W$$;P&A+rHWFcmKC;O-)VL>1m&S4#VVR zL}8&Yp@4vZV8z9R6o7z!x&Z8#O!RLexTCnVF!(MgE*vs01HbDC5D-3)xDdaR>+1Q2OCrj{$Jfn~sfqQ5 z&a$ze_UNofQvOfM$6!p!^Fdn7_OM?CyL5l@8D`YFFg>ntBrQXg;s|HQ>S}5%uUu@h zUfw@j#{Skp=Mxfk;2ou6AIh{d)jxGz7MH;dZRP(GfFKA&ctaMX^+WhB_IC}f^0d!P ztGMO<`OMjvbjH4S9%6f3fxz!BVk->rzFPnosBA^)1m_6NA_V2 zTJ+%akuq6hD}0K%?UiJXq9R^@6OIgzB}71*izSn!hZd@j&!=M9S%9yJ-cxiYufAEv z4+RR>qw)c2wbln79nKqk84e?QANOM%)M}dKZm92VGw6nP(+D{E83UiRfRi)>sil)1 zG2akPhS@|xp#bIoz>X3mrw$T_Z01#T$vWXNqOa(#!j(GNi}`xRY~0uF04BFqr{(so0*ob`6Lcq^YJ(`8)D z+vm9kr>Z@_CJ~fs?}~w=cWfIMhvV^8^yTe<%>JQ!tLLI_pHA?bA)cew9`ad+f-5{D zy+aJ{Gkix+p@Ko3EiFp3op^Tm$pz%iVPif{U`(iW zB8<~+RlmvT_=a+0W5L=6 zZHj_rMl@s``45&qO|nf@#larOn1<5ApGt9=Yx}jJA={BKL+2f2X3&8oa{QSq0xFu) zfinq;s@Dy*ORp1XcWYIsmfDJ78TC?4n`m>}@l@}3o6B&Op|C-07+@))?74QQapIXH zf|;v4?bAg=R**hoS01hkZ)a?2ab+C5aSZ_(5qoZY$#(AVneVdM6qC5EMhm=Uqkq7Q zk^nx=XPZr&VTd}3%D#3==HYH;yEgPhv%m-$+J)QMx|gJjE9O-$)~Y_9G^HMwOr|l=-OfgNqL6ZB$t{s$jF$IzYi?<%mZ)6tgWFFU><> zg_~y(s|)Lz23i4RZ{o6#3Gv$`OimUokKjzn=gjpauRKEe47hEd%$WW=tXv`gAi_Ww z%5Ajgm;sE)Mz6z2^y+ZuzCk37R%r@Y9>@JES^Ki&$z0ltXyBlcCK-pL4t~bUU)Rep z6yB6>fHBHSM&9AzYH+qv(4^cem{5MS3`?!US+RU+JvqoYO=%I7d|7Md^r3ZZ(eAc| zpXTpUofFA7gXHNcjy-@s9ShyET7fl}l)%=Mb^un|6$o?`NgRQS@0Xo5YoKDx;;+=I?9b+$ArptK}P{ ziwc<2yBDg~>z2wahG@)%l(n4qnwKqTRjYoh#tv+NK7EPk5b79!PNNSRZ_4lj0L7ZL z^gV~YB5D&3w^K-W;6R!`|b=kWx&UVH{t7T4kOd!{)g+v%`|4z ziYKcL$sQTcuz5)1<;!cgjmoZFYif60iE?qgBpI#{Iil4b4~=>4`ScDChne2zqSvJM zOuj@5jX0!^$kUYsIpRS@fynu4Ed4)G@*EY!eeqjER7qqr8`^aZw_Wk;S^-Z|n3yla4*U`KmRvTgTS1yea4imj|&=u5xp3)`^4}q)$_DvnG4rh|~rNJ7w?H zZ?mq~Hj%hHWD2h%d_mY)A)f3XL=b79-nk;m@rZowQ)*Q9GNOp%BE=Nd;7HHU%!9dD z`Ha%%Zsi>ZDf%c(udSe3kL@;2q1le~181b*_)Pa4=7|U24_hT!Bq?y;d|J|So#Y-G z6TLZ^*G8LTdbW2!RI-$rH$}ydawaMi787qN>c#bu=w{yx=A0b5gh2MUHQ=7g|mBaFh(01tD~COhN;|e4*%#7y9QmUruP_|R+C3BHO!7xbl}(|SyFx_m zGjsY2RfMnWh}ss@*Pw6x)o!F_j|$;H~LmiQ_hMrZfU@5zfeN^)^mic3mt=kjlOv(HlwoJJi8<=3jzD=E!0%c*F~ zYQ*~#zg@RblV;h|(~cF^6o%8GRSeP_{WkiCgIf@8GB68yQ?&3&h@tF8WEwE%u|4VG z2oyRA;IgNqd|k`^UURH_j3|)S0V_cd*ssG1v^ZH4IHCvkuiH|auU)qxeGzLhlb28# zD*m-+MLK-Qc;%;<6xF3RaBgd3VlvxwM>r#m@Ezjz0%Q7;9o7*S7R`-|^K8K!cIUsp zMSJ}Of{L70?Od#?g8I;aOScp~wiQ$vW>Eg_o`>@ZPl_s=obr;`TjW0K7+*%SeM(bI zzvX6DS84#bi>aWva(R=_e;1V|ggXU|@y^*?&LE}qd1^$P!@`WT3CU*Yz^nvr(DnKZ zk$Z75Ru2;j$yM*wWRed*V(+!+_V6i*Ps{3Txg@jeSenWi`*>bPA^Q(b6F#rjaQK7q z{qV%QTd*^y_-cvIKWo13<7O z7~bBTQiBc5drdyCE-)rK^{@ng{p_VrHorbz)2(mKPhO7RtX@=09w`lh+lhE7C#S5m z{d6Jbf;rosa_Hf)kK46j7WhQ-hHdC@e=jPY_XktG6dya5#8fh^ggaHeI-j_jvrm zag^k$=Y*Yx?4vPxx<`w3Mg}sos^Fi_y)*_5?M#5fMv`H(Q^oU*Mm97T`_bZ($I)7& zr!1FuH_)Rq*Xn1g=H}Gn8stB9r+MtJeSy|yRuytMrxCGQCO3Xox?W@vNfeC^{91aA z6;haa^l8?pm8dfzU*A~X#-4JhY6_iwG&oLMA$paD%er}CT+5f|C*dfLW_gd@ZG=K%56JM2=Y>TvHyJjLr7 z{U1Tr^05HVnu_$zC+b34Bc`*{A3}y)C7e!fab<$v{R@QRP5q^JLBE9oz0z6EP^umo zsID~ja!PHRjs|C~f*jXz;=g%xEqVGt4KsGKAj9y+W;P^*FIz9RZ-`TqL{}k zw7kq=Yf6@-KN?_!$7h%WaVUQ>cve+r(V*H`7q-y;UA7kQ_|z6Y!ytn}s(jpt=N_pN zz!muJw_9!4f?7x1V5?7~GxpMjAMl>-bTygVD5%$G;7DCJ zrTr}52gY!d3VBOIr>k*FqfR? zY=aEpmZHykCPJJ*b`AH7UZ{Ds>tzoj8Uo23-j~yu{k+(X#X2F1SbOC-?9P5O*0td zBz4!sSiQ#WULO{FjV&rdbO0P#M;W*Oh*A8mm!Wg+ICl4NF^1K4G#3Wd8fKbjZmd%m zRZ#)YTp2Uq^#=@Zm0S7j~d7t(luM5131xE)@$LW=$o%XSUCJ|V(7aBTf zw%STLEbw2ry+&RVd(cd}TBGdY>WtRBb?@CDspwmzx=j2Q*(b3d$N=rAFNDfj+uoI5 z$zhXDl9x4x zxm(z<|8Q8x9BBRNfRtBL`&&K!ZoLHR^nNiH1%$o96;&aGCYv*pwFNuAeNTY#eiula z$(66|px2@A30{Sj(-%}-q3@-v7>x+gZSAs6oQ7*{clnz(9 zTi_hw#SZLZf!QftDP3kXLHe3@QS`M-%c{@Gjnh_uZ+AOLa2VQ>3UZN5?>xTTw|Nlg zR#xs!7Tk$FrXznip{wGoXN%iZ?fI1d(HwVKlfn>UrAgu<{@z818zeRIfbHF{9Xwa1 z@7k17S3^6Ju`z$_Ew-p{wv^4rwJFKUIMjDDuW@4qg*77XUwb$(b*CtSkzYV3`ok4y zkj{ca&{pVX%e?-uLYn>26qzo9)x7YCKru=WiAEdy{|+-JkWHvj7j8BHR*_htq8O?k zY+$S&n`F$7`ImJe>gIIrD|<8R2A;Q(1;KXSSJ{;KFO*7C4N2!mo%z}A@?-G{reT17i! zcsVOB%u4;&yh7x#66L=oa1g*x?*PGWb?~1uu;mu?W?ApMU}4`5E68A^{$npA;uiGO zy`vSIF%1~!_k>hb*m_-SWh?4`r%B)~Fx*EEQTd*(cL~(t1;27KO%kk7Q3`SMM90cP z=iy_%FvW{u+*U80L#qNn=v;OMK@oy^N<6Bq9HAp%$Prbv;TVRq;hH?FG!L>sIxW0@u{tC!|oD6`31V*k@rW{Z0?N5QAtzR0C95OX<#;pgZSNIU37NaGR8b-#2EHPXq ziGo(@90YxmCL*JY?#@N#CFI4w@GcnRz3o*oE&?g&%86*mpi?5{$sdvYW~DoBtWbvV zxP0;0o!@@=~hNi&P zROu$;JU?6V-@`XM^_ZpM{l|K()4!P!^B61>l9GaF;V|Wb{brC`8W-7VB-Bv=c{MoT zee`k?EZtpRM<#vr^6bme)~_y}A2e?ZGIKYHetFDI)*7bmOU=B9V54#qMN!dvxy{f; zTwG76)f{k>&Qoj2hRrW+2v#2*^4|_9A48HQ?Kk|pcIE!pS@M`1CC84atmp8&%wNbkR=ZgZI$vS6Rs zsmsd`eZZ0|ffk|PfOI^c4S+l8s?=>+1r+L`WQ>-F8Engr=DNgGwMYdqvM8UWd|Ow+ zCH8S?OA)vn;_T62pgZXXixq1^UgY5iHwnoLVO3d-48+G53{Ifs=wHQbX}A3o*eQs* zD)+s2*w^+rIed-`UURz#+MsvE_VaV7<+z-IYt>zNM;&MAZRgULS#9ZHHjHFB_JqyY zON}pmCmY>cf-|*f^hOVk)*hxD0}Ob3(cGCKlQ`o|Iovb1DC@3UU(aQ^!Sm}{RoRZD zNOzh|FEl#en%tKQX2fU#42~Cs5AMmI2VQ?FG=`Hr*)+vpfUQ`0ZpS^4x@IGe?i@u@ z?U4^)CRdM9Hv73C1P>MpgEcI}?BK$CGNJ_QRcsWG0^(DF4Z11==Vis2b;$pQlPt+U@&shZ~O( za{35{cc(9j1B@8!&A_xMMP@r}i6WkkCz}bE+79B*1EGUW)+pubec`+Ax9*RIdSBVZ zgOq9wt|SbdMg9GNwz}ZJ@#y>Wm56m1w6kQoJks6VbR9aaSDGLe`8rM~fQ2=9{Kus$ zLqv4#&YO*zJRHYHTS88X^haBer>b0#3RvwR6e`V@m_R$X`~5k;^_$~)Dh>P-Zt^-o z5S$3`Tvz1^Vv6fw0<3G_Lq`|ubjRlGNyIT{8Mq#j6EYp&Gs2b{kL&5CIJeJ>S#rlG zh-z17m75nA^0sa>-=v{(>-*g#mFrXWkmA#WW@M)egQm9tGTpQPr)j59#gp7++a)nA zcHHQN>c-@jNiyqIvG>RkHs=eGy=cr)oQ3S}^?a1p({Rf2+B@7b{T0*e`ZHwf9{fSK zYEF-w2Ztc7caqb@R9g%%coQ%_akCag?NrKsm@fVS$8$U*12mf=)`bM;LE=LDah%ca zqPc5kRN?}KeS)Eq3UmAl3uGn#QblpD27|2_UVO(*XhK??$aDKkg_>^Pv~}qX0$NpJ z?VcEz<2{>l3S}m`H0%v?h6C&mM|*D9Exmit@r-f9oL(f)>ynhOAbC(2xPj*a2iB*V&msxMoeLb;HN19z6wRr z-8PCVcuo*+_u$bEr~6zl|H?~)kM3tHf-}5^aa6>>6_$tFZ{5uuJ1-h@JL-fyQx<^R z8XZ9h;(oJ$a!YPd5tJc+0K+}0w4FudX7LQeD&ji69e0B z*Eo;!oaukQ+_2+vRvj?Wp)?9WP>dk)UA)a^_>MuzX(JIS=?L_v_--*WaO)^^hL6@s zC>6$!#)gasy1sR+mSIZCBH~Uho>xh+nS0~StU~3Nl@iO6O_v%PoFN#YGh1p+rrS^C z?f_p?Y$R^QAJ39xO_X;V8tXGGJVcopTi%fr^ONj7S${OE@W#hW|0(a2o?wE_&jTx? z9HpS4DJnE#(znv%@|~Smn%{mT*{N$Pe^m`P^I~Zl%kOgK5f;WGr=&D+&_>6JK#UTx zds|JosNB!s*s86qi2uE8ZOujgNSuHKH)80e|HBW$l3YvLT!fAmdm&_bH*Fl4Tyrwg z40BH*A>A8$xzOd}7rd+VETen;n21RV8;`Wc3>#njK%6I}4t8cA#C44L_kXK51u=Xin(SB83%Z0wW($-DXsGE1HNkMJ-;Qu&9#SQ=%D2y ziZwAQn;OIo77>Y?hsEpTtC*cS$muA#Rmo6JYKSovlT>N27NFL1^LM0#o9g3>zayjm-q ztK%6J)(GL=8r$}DO)kZj)tP!xR_|uNx{iH#bbjmH@W1|z$W>0m=j$Q7IEuD~^foaP zcwGL@@Sb9J$WVvWdiS+Q)3kj9p@kAUS-6DtMhwnxlSW19nm%|9H;QxgER7R=c9aSg z7q~Dux>d|(I3$P;ajhS*8BOpx7(@ub`SO@B*KQi#`>pG1_YqD%nU_h^PQvyzL>!c_ zgHmz?RIF7C4{8wv1hSWD8g63~F7<_-FyiDA6E8358$^I9aO!d$k%|uBbadfpp_!p{ z{X^885Qc&eZHOx&K?=}y@Bsc<(zvEvysy#Ne(jbd(5kfg%*N~&?l{>d_bB%a7 z_E>5U8-$D2K%0K{Z9Ut6V}O%tu|!QaR>!DuMurVqiCjP~HuY;-7Hg{Z(=Dx;=o{&L zk%T)?qrl#;mWo_$_4ev>V)#@vf6txL6@xA7P4sxMCY0PYeoOq^NHQ^W;)$K_kdQXi zv+!$*la6MSn4JD4HJ%)=N6^nJr$?NkM+fVM8DGyB$~un_`eL4PVhu%wd0`<7pc+1G zqlY}Xpr6TQ`GjjF4z{2ugVIX_3)VLXDWfQdOo&_hbr-T)frcd&G;<39Tt?fCCsj``x&~1h7o*&_(}X}nwk(dYya-9)u;nLMTU_fEDObkI75~zB+j-g zCe7jh5Q;Q%cQjw>ps8X$FJl>} zlSk*25Jcy6I+F=6wT0o^uxC=&Xz+hghD_xQwkn#VSd*2r_`TP%I@fH&L!~lds^SMe zW*f?G4F-$pyOkl-jhm$|-{Q|$=N4p&7EDugTtDC`#=H_OK0 zEH+G!uafM0gJc-4gBq{&~tPy`HuD0)6bhs4=BA;~w;x z)WHMi#(~hRd8f6{hO6iAVz~sEdoqSIg2vEB2Rbmx64a)vgZI zLFTMk0gL?xwTF-H5*TWz~95othDQn19YnmaOd+ zGTe`kMM!1aa@@c(`$9}j6xH$gserq;MB`n~n6N6UiHu&v@2ozgPlW^$S^9^?B>NZM zdj?BN;Ss~lrRIn!W@230n>=Nh1)Ky^OA72-Hws^9MlJppni6jttbGJR3}DMa#S977 zZB01)_=B9CL2eTejnGV`BN$W@f3q~l+<|$0Z!;tHI#;UX2tCa@*B_E9nDxu1(_Rg_ zEoZ*bhUJsgr6v&!FxsVPQdfmkTGg>9gEkEwUx2y((xs=5D(~fU9LG>$lvLyaLmqP7TTs-&99Ufs!P6M-?!VR4_azge;yC4|_Jdywe zq?WbD~@%Wj0yDwOu1)-x8?N+PmPOOyH&&5_uDZs8GFPI#__^~zT6BR@VY<8 zun^G+okodS$8pd##KFo{GjZe?DM{D3V?W{rpRni34pfLyh^Io83uQuU{0+)9`cnTt}p*f*Ylag7$~Xy#l-GM z5Omk}p0KiSUpU>Caqn$og}UE&#Q-EVL2)l%lpNV2X5_7>j#lWId8VVW zG-Q-Y!_{+P`-E>_TGR$>U^otRtz93fO2}x-#|X@3`=6h9x^JL+)g69e+8@v3gx2i` z81XS}k>zj_L?)uHub*%HL+q#PeD($+tP5u*c1!hhVb7}$8#z@3bUbkjiaAH0=0(`_ zH$Q2y69RC;t}mfu?q13`;rO2ZL|-O_Qq}maUHZh)is}khU%)sRv#+OR-TdD$KxljWC%t_Oru=K@Fef}HLEM3Y#&F{m=={#(-Igi zo0TP36n`BX9>*KVH8+8P*C25*gw`BT@1iX^y)&e#_qR(=Ge(SwBuEOKU=1h{Z zM40LwWcqF`{5Ax^lr-5solcR?>5Q1<@vo3d*qtrvkF72E-kgw)EmBzuNBv)C_8xPa zNcyYw@bo8T8E$&obYMp>$9z?6OuF8wR5sqeK}lfbc+hn5Q(tazj)um$ez}#=w z1zO&7KH{8vN?=YWlXxDLhjnLH`{5KeOwaoXV)v<@H@kvb9venFYH?!i#PF`v2D+V7 zgU@Z?hGqHPE_P_ymg*y zvV5vTgN%G5UFcYBSVqKc_=ts+3>nUO1}3yRA%pr>{IS`cit26h3}qDCeuDg^02fuH z&{}t=#7L=td*=}$iR7D=c%fitCpEcr!KKV+B@@A5_yk|FZwz>vGZz~p?gS+Ib`zm# zJ25nGIl~^^-x#=K>%7h3lnB!9Zu#(}P4fDZqcjpwkKv>K02T6wogYFTH7}%274S~Z zc+EFxI2nYnDP+xz+_~s^Nzon2@@Y+H3Mn=KqFf!W8wCnJ062)GN@rl!tcS18+8Zzi zwIFo2%#q@VTF$7hKaJQEN(UV|=LWu2xyQHhez3E8v_qPXsmi6zV!4>HWK_};ry{^D z#K&fdx0q2tQc$qx0R<7@ZMpMfMNb-@6+-!x97rcI5N`7^Z&*f{;pcX1+{${@#5i;?Uc`TK68ht5CGI z)oPfEzFE&wZ1GT+g~Ln!F-__HQh&#e&k!y%zJq^-3Gh-4WBJ34lh4AQ6_Z=s2o;<; zo*OLN^6cd{ld z#eV;)dfRvJvnR@7o%_89r$P45%TiT>yWR1z7Ck3}wIv=*&w;F|P_vY0mT1DB>T;D3 z9G54v4syT{j##@H@Gs4Rq=C?1AGKKd%OE)XUQcF>}RX zmq$(>EjowLPoO&WkFLMA>8H|sb`N|e`Wa(zZ9O?FGXPc=IsS{coxc_Oh5rjN1P1;~ zt@|$gFFYn7HvBEnzbS~f|55+1`df7WqkbEI-?6&Cndm6Wjh58qc@3Eia z73fth?yroiWBY6JTP?2Fk2U)MoO9*(^LORH4?f;GjR1ay{17qxz)F};$38stxTbcv z*`5uT&QWO>optUa9q5?{?vJ7n=x8&#r#;oh8gG^*9=3pR5v(A&iy^w+OM}pdfClifA-&l+YieC&^Oj#on3x_cYZMj8FEpoaYR|e zb5?k{GSjWUaX)!!ilp#@UtjMN9iwLs>E=`s$~C0UMlz$)%IuG&OyXmuK1^b9XSp*Y zsi19RFvWAL#=79TMN!N}44-p{4qavU_)K-?=zMhrIWXyu7IF=@o`BI_o*%THeN6%+ zzy40}9PnPSim-ll91+r&r8*wgXTB{vMT*(d!-S-#0})^$#v_T_Q{+d%f;2{m5&r~4 z+=C>Xwjr>Bl~O>qz?xaY_+LyTIMs0L%pz$4wP>M2*`A{}KJfo-8cazQ3m?kek2vXQ za7G`BmLg#qhzJ)hDS_TWM?InN){4J;N|jTlC})H-TCL=hR*w4(E5xO4IXuj>g%N1LeB&^8Nv)$umbgm zBUpq;R=>Y0>0Nik&Lg;r{U>DDc%~-o%s>TP;oV~cRBrl`MZ0}6NXLd$t#O^5V1T~A zKKgjtDFD73U&egdGc zt~*_+7gv%om8zYlZr%~iGmYfl#T1*}iRu&vuS;&3oOc2evo2j-QNsZf6PoJZXCjxM zOBNl^6MuN!_nbbT_w5br6CSZ)Tab3A0`z_L@8&0-ZpPc5$1Ko1Cya9v5oy(X7h-%S zmLgPa->hWsUP*mf-Y?HX+>AFhP^sUup{>7S4fM?AY4S!9O=!yt^X);|_;?~#B2w;d zMaN1vLTIxR_aO-$U*TQX7_QrzB7P)8#?g-e!{wX3BIY9HD3RE^zz3*qZT8nUzegRh z>Yo+p)#u{SKKh^qFIG`*OERcE1~LFL%6Z^C(tfM~oc^_;|o8qU8*m zjT!7;6JT?YM$q;?tSdPHbz?2SiL7XO4^bMBITGxT?WZGw&1@Pn@>(GkcXs+FRIjCv zE7m!@d=OmNR^Uf$xups74=h`U6}EfO+bNNaF45>q59xE;%i%Uv^W4#T1w5EO5^T{u z8OJ1wv}z%HJrf7;DY7?Bh^uOi;iTLj-N&HW&m0J<)#wqaX7gm9Ty^x^6l0J1m3j}> zh2QC_|1Dke5Ic*VDBS~#vldpn-7N4_fHifNBqd3o>X*l%ph*`MRN#u{t>M#&vB+t2 z{)dPjZh6rW4BCd=@4_g>Qc^ZL+-Gwv`?y}j!Bl>eQAy)vwD?7WxaMY|B=&QPst)|S ztOR-9{2j=8V`!pJki?x&kXV~5Ql~u$P2T8#xsa=Yl!KfJdlkg$dbvq@fE^r!dFPjBc*@Arci zncoyTs)Wg)xwh(HwHNMI#xB@{EFY-1Q$sq20Sgh})##hfnp;~2zmtbVA!<{Kx#2pG zT{~=eC;9kvCSG?ZIv)1t)^$99y7A&+f=8}G+FQIv(eAO#)--COUf=#J<815b`--|FsiQm1JUfNp9;^U1-x z9k@ejd8plo0+1LMXZDnhR-frsb>VbE&u|`YVl(uSl-Y~E&vV{hn4@x}d6_fU@iaqW z8%)Or*!9c45U+YuJmTC~uAlhLk}P{#h*I(M=*QsQ{K5c?jPuyX%R$#FBy(V>xm<8B zEZM_8T|&-}?y#+_{wTIADQav&+sd(P3+a^i zUs7FqzwhqIyl;klotXIc$0s0#tU~w;hV1U_c+gkaQh;qf@xI(YEs^Oj1N)xOb8PIJ zSb-*Pjq= zv^u<=(yT1*J?t_n-&Ny*3JFF9TDc86H{Z$R4D&SWbZd|JBY4P2OT)gryx0@;+q|v$ zpn=u4p5aZ|Jvy4r$kQsf|H4@V?WTb4mhSh4nPY_HU$nL+4i7?TvXOV5?gsjnl z{>u%0L~Vb4xX=^Dp)U%G=O%bdIV96bqqU!%6O)l4EIQ%qF1mhu*Lod>?r<3a!d_bu z*NSd-c@Za3T(d0CK(kVBy7)u0v=Tp?;aq$7Vcnedofh{Iuy!FZ(&B8ucOM|^;LI*y z#}5kr`NH?!cnD?fVS};;4~7T%!toWY$=<-jUW=d8b&Xt2O}$g1C~I_=VRMsJK<`H& ziX(dX4*y&NLtVt7zYFPkd+CAgx(S=fLbbJ=|g6VDBXLe;%{8M2w{ z*~7ujW=~5CAm0H^+U_d5yIA0}? ziDk9^3Bu}q$KA}sf+y-d0ZGvBy%%gT86Sa9yx(ZN-CNLdJ2*BC@H?0_+Lnu$se@qN zHJq34bbG&s99Iy0d0#ktd2hi&XEz~U)U>HX2nx$To8EEM{P>|LxtD=MZx52w{e%nt zAbwOwRaMM;>U)qsmbZt|af2bU(VAKC`M?^ZtJ>ecU?{HnfZ)WDudp zPKl<^mp}8&Gb=9?`oX?0oc5aYbv-(&Al2jeg5k6{vF@`;uZJVrkkNxCqN(U@_BW4S zrXewYv?qoNa|y0j4uTMlxaGGWQp9g^u^RyAc;fRslQZ+rJooQtxLtXr)5YkX!2{8f zoc;2}z1x)mq2}Y6b3h$^lHbrpZ{<_AFB)7(X=zn5rO{3KO)Jd1VYPX-`tNh_eAb7Y z4RL8fv8o`1H`8GUyYZwY`}?M-^>idn^x_GVNH|(ux-N}csCY!T*WkMn`Y3JdYqb=X z@U{u6+74E_EHCsW77z^22}vbgRKpe37`s)HIXfBdggw%3nFVRwWNrr{(drU_=;LDZ zjzh}Lwh!;Y3v{sUCz^+ky@rSlVCERI_^J0+U}$&1S4%4%q>3j)bYodRy`miqeTJ!Chvf3s-_Zh&#s zyxz)4k^P1{XYCozMO3ZWz0+(5Yy^RFV-YT7>`DA>45>EC`fAXeZNnu zYaPs{5AavwX~e~V_{4PSC1*&bd5S~@HCpKAv7j*pPYzSqqm2hs&h~=+=&nG5K~Bs@ zus%W_nxw#crFUH5%3jAee1>1mQwxsqm?>l?uRKuyE4nc zEhf83QAZwJ-8r6^Bp&P(9O3Rh0V|NJGsY}=vGo60x;@VXQX{>*mYU>w!@I96>y?AD zvN}8<1I*$xwfr*y4ftlc_ktuwj1P;i7%S!`0SDTX`WMkhR4L#X?C(c$7k7n zOcv)z&^Ic1DLIH|BbDr5eRbsEre!x_8Bo=nLaRkej`sg&DSCf<)&?-$o)*x&U7=I_ zU|Rm?ICe=8;P|&8|D3=7p9T3{_~-xqXLb^QtFHgQ>Nj{v+FqLg^VXhd{VTm|qm-e5 zF<)Ey@hYif01UJwhme&h?PaxuzFUXdGQ0cOwh*RqD*FtA{F^CEawRfA-`BYY2g6bG zU%z3UF+00!rQe}(cPQ{CNi&pPshHhLkI!)===JrNdt@+8{Y<=N-G^|O=ax1+)97=5 z6$z8g=SIS8eS>6Ly;+|wl5N(EVnnIYNz>6F@Vy0j4OppCWPrlWfyNbm&x#YtxTZ=d zSvgZcTcfD;cVP6}y7b5dRwKvO@PwyAsvZhrq=vEDGn@83aNc8u%~VT_trVm2OlT$w zxjUFIfK+1BYvnG=!s#!NLhcn9G$yOJ6afbB5)4buwpA&Shn(N2@fw&b4kJlCqSHRSfS5ZtS@6q6p4|SP14gPYpOu zo_Ejhty7M8HsSv<(+>u=f0Rpn@V(uE+_hvFE$d?~H-|y%c5mt}^NYTwi=IP;nmL92 z$3kEHrPv0(5%f0iM>^T5iUK)dXtrAnS*f@4Finx9kI}c?X#SH%d>GvN z1<(vNs599t{>tgRgG9A?1}&kyy_ z#fPGV%Fnk&`6Y)S^b3UF9x zzjqc`>CBaC;;C}GNZyN_`i4O3eiDT>8Dj(`g{@~*!QbmNvBIk01dh|CB-GM^OnPQ4 zT;TIlyMilqs_6r9Ne9KD3>F6tWQ9|f;F!aRL?>9aIp{y+~vd(cDu8LotD!|Y@ zZ>Uu(bBxqf+M3EnUam}`n_t;KUTP=r!t-m(=9pEsmvSWE-JR7$8fgq-caHjgx3lB@ zuK=SLe-oYxq{J*<1q-!4CT4J+;^`f&XjZ!7ZT4(5d;d7WwfO%2zisz>3!0$QuR zV5cR>uQky#1mDQ5;d8oKgR1&SX9!9|Gi-9gq5>tM6p)p!gIBOOyhHVHPKd)w&B5TB zp$n9r_p=l&p+~S}90Pl8@~JE{3II~!5nId0KY!rJ2BK9{%NMvK<`{#;7U`Ng#_WWJ z6Y$c)QIJBNSn7sb8LqJg>qF!{alzBc)BWtl`w~%77LG4uB779eAr{&Ux-A@HL^4-H zt3a|Ak`6N+H^gZ*?Z@fBCyUqNi?eRxk21k|c6()}&1%6Dls39|*gJ_9muqHliXy$p zaF{CbNYyE2lrMwcu%CScJgzf5>U4!g7HMS({aHh8GB!{{4PTRXxT58R^$s1v^9mIi zk(nVrD%r4F6Bft>z6*WCiYjiwCZ?if2-6pP48zHrh|5{J8%zg27VA$eAGhL3V33&$ z5mqge1h)a5zb@IL?5W4Ub<`d@u-|bE_c)#pQT6ubotc@*X9V%z;$L$5=?4TCvKJY~ zt-WN&nbo3IvxT0L%Tp6rw^Gcm}V%U_JUzQPE#7Z@C;jM9ubYG|!V`eS0>(Pd^Pr)xhz znTx)_!eJ{4r&i{dJ18h#StxX-7tidSuiMk$93X!22@oiaN*h(S5h{bx;heNhyljCH#Lj)F0Ffa=;Jf^Ok_;6V%CLU zHWYwzuTMdnS%&?(jwfX2Ar}dhu0o2Bf>Lbo@v7sr?O4{$`~DMC#wE>dfpzAmhPppH zI271nn1f<`_-+G3fHCDa96M5^_5uO^AI9D>IFl#r9^QE4jcwc5;LgUjZQrqNZfx7h z#Ug=RZY)ScTMA*>zwY{9W2G=E{ZyJCI&02)ITK`Lyn_1JUbLe zM_<|Not@Y-uBF4^i^Vs><#;Owm0waX8sDBJd#y~g(AI|*bJ+`XFA zB4s}wrd!a+ZQd{#fc>6jo-v3CFjRu2ssI<}D#ct*mJ8c)8f+!$Su!D)s&Y)>bFc5X zKEKcgn0Nj@nbBg{F3cB79V=qMPD>DDc3tCX{I;)`ho$l!v@O{)KiOKtq3C_! zYs8xfK=LzI+UP=C^s+MhC$4t?f}LIa=eDw0FuY1k$!pV%T9-R#JKeMXn#%2CrRiH9 zQ$ac8fyX-kXXV5G#hTdUWl;TJG;*dMU7VKBo3kr|wOUdw9mFzP5j;@8Ki3RsZtU1> zt}YwVQf=i7|1{YPy~<67bT7D^_tprK?u6P(wsb}3lEOm6di^5j&?cq(Dhtyp{K$~- z@qz?vW3-UkS=%XU4$kCuMvJSpW83X~Yz)R4^yD2~E@kX;%(25%w?y|)DLQtQSi8oh zS&C|&!sz=GY$i&6Z{4kw;5QSm-O7AgSN>Cq8a9zNBpc;ft--4?zffN~p&Dd|%?lPr z*`fTaBuvaqjXO|p?mZ<{t4px$Q>okpv4c=;<`a=ts|bIyQKbaCHe`D;Xe#U zinnsmi-OM&bVOwu(v2nUr$9o%it6L(aCox0&qCcF2~vM|QE<6Fp~#k!=U48@P3lnn z<0KCNZA@C#E4a?~KyH58k#*aSdd+!z@m@jDcsLIlD|Fc2mN&8i?-$yQQ|`?-v=wrX z0m><%wL{N`Q|o>Eq#WsG)Ut%|I|fabrRS(@MYdTbN8naD4e~;5%Q^e<2-!i4@849Z zN2odRlw`t8wx&Yv2CjVV_4nY4%pE6z-iEgJklu3bSIoyY0&fYzgdr;TRf>GIWgk`azoSlbgy_f45 z5L#q)g0tMS`)qGJL09jyi=)!%$eVA|njLhvbr=#|#B|w5FeCFj-#Xat>D$QW;fcK! zfz_X$igce)5sz-vL>8lz82X0_$_x268*&|2lZJJ_9ZLgNhq&WN4q(HaLQN)F!bF~a zj|0kzeTyvFiakuW#bmTSX4EPK1O2}r<4ALV1wE;lwni zf@i{bwz>12p^L<2zA&`pVtjiE!Q{0;JO)_@v)1cLU|N6h#=I+j5=dN{ZqT;E@A5&w zxsztv~;eL7Cz-Zak#@H+B5*R;n+2$U&n8;0FcABT$OggJ^Ho{5yVt0e! zHyCrPIVT)uzHyIP_eW;uE^}~tAl!>?wv%`?{}-PGkws}bj3mz%)3-PM!B09eF*(4} za=F5g@-pd~R!;tcfQVxnT{KqkK}ioG{FCSZ>tun$@kaZ*1M45}XzNS#r~5qS$ZD-o zz>D2jsx|?|ykgm$ChKCdP@ygFBKDCER)out0 z#Bf<1EwU=HT*er78(I7nt&zD3Cs=R+)5#u?0@}ze%VnQdr|X5iJ3WV()pS%A zZ=GGaw~EgLym}h(C9U5dTOB5#obY($pX(|Sd-A1vHWv)%t4yK9IV=4&LUCH3Q+qC% z)xUSFNjz(DVGIPa;v(c0S}@M~kO0A6l@28-=%ZslxcX|+JSxJj`5$W{FtUUy)TB*9 zLpzbgGleKI%D47^1!gCLQ<`0Ag`pw%)wtZ$)lng(fSTk0G6da2`S->^Zd%9?6{%R0 z91-%EbJ`u-MgkitQ1KajT9Q8^A_!^5hMMe~%iwW&Jj0FR&WG~htSGXMJV-sj+V|WB z9pmEUgaWmUT4_6P_LU%h(gA7DKmnCpM9s&TBxDg8>lpj68RJEXfQSImYfYccx=-1W z7zqEmVTeq)mTM0aPqfJiFUv0{I|oOsEtAC@ua9}yqRJ^@W}~%6^-Rm^q{hW$>|A=P8^**OYM(TJ!|rzJbco=AwMv{AHP}L+vqDo{p76QJDz(Pgq7Ro{SW z8Q!19p}W7f)j%yi9PTgHME2|FB&)^_Kj4{Z*s2L`fXL5ELrl0l$|t+-2IbK61~x04 zuK8vaLCoz;Jv;SJ`6}A5&J&ZRfyZ23_`qV&+zT*-e?o-Hl_9Ukqq|-`-KLgP@hBZP zUb_%oa2B;Osk=A?v@43{AVv@UC%AvS5Po111IixYyVq7yQEke;yf)Y+grSMYMkCTw zY4t@YQ#%Id#11lt66avBP50~PCq^s<=M%2x!XH9T-KQ-GgI^8S&l|fGInC6^tAe1u6}-Z{+#R(Y|NQo37j(Si{ObRv)Y@e`Nqy@yk&00EvsZ3!7v;j{|c9tuprU{G> z<`!Im{Q{RBZj`1pLiVP6-mpbH+oO?W{K}xbjr@9giAHs?cOz<0gwqwpxX5W{HHI z3&siA?2#Cqc5;>Oq&LtQ(PUQ~+JShZS8lYhJy(}5X$)khs3)<^x)_*_k&1rOz*QVs z`^*^VvD#6i#F~&B5(3i?qIAGls(1iFmXUU06m2GRJb%^1;QO`wrcP({K03$E`bK8V zu-<7v^xZ{|Kv{iTfM{^z5TM#WBs+z_Q_D|&3c#=IfKsP&xzpVkJ8quQ-~NQL=XJ#$ z%=3htO!lrZ2+y*aBfd*EzKSSF?O$U^-$&sIQOSs#xO#ItOg)~GIW@uI?AuPzY$ySu zi2rY}Z|c0z>AZNHYdXSfD>mWJ;IWa5V9JqTBHJu<&Zm6S*wI-bXhwc73HrvFPUYh8@tn zB&B+IFn%0Zth2rvT6@-TSVVqGd3ErKCIfmXG`xUslqXA#DKH$W79j!e|JL z+eM~sZjXZdc;nrwMacoe>IoLi>Rq0_FgA#rnHzCe#p?nm=8E2dGj`9euN1CouW~Lb z7Z)OX>*5tkZ8tq{RszXwH)u)VtG7LGhJr&|%s~fa6REs!`s`gCNjur>ruGj82)IqC z*!xCD`e1L1-jao}b#65o0|}q1x587xaF)7JRT>?W|KMe@rZ2R`c8s%c?_rTVe_Nqy z)^W>Gv|{J$W&{sSZlA6l%~jpVg(K1$*z7?_rJR2+#g9S}>iu_pw88M%1IiON#=Rv? z$2?Z_Urb51A2Ft>ubXChLQv#FS>GJto%iFZOS(;GJie|KExcOU2>SbkqnK%{X*!&2 z{d@`JGGGXJ7HRHs&M5oq6($SGX+!=(o`<@wQdla@>D@5OO~uQ$YI$}qpi_bC&Sb#w zs)rS*ao>U9+t=nY+cA;&L#eZq;dUZ>H}!?Uy^ATRr|AWY=h2*gLVJ{9#EXgoM1N$S zc6~&on^DTY@K1eqj`8jt$fj=LYf&^n|Kkx)i$jdu!G-Fq)_sl`>7B2m&x=Omc2QBe zhs#}IEM#@FAI6418FJ^x`BrCEc2;wwajmfRrnmP>a{xuQN5p#x%k8L6C7_|v=88+tP6bcLW|ieZ+BTypK>Mrp;!4Aq4F z9LDn7g~qCL=fN}iTc^74-^PIGuC2CrOAB_apMN%-rC*HKIgT6V5yT z=SyZ?E6B5A4*?Olff z{~;(*=yoS(JpjXIDF+yb7eRtH@zv@Z>iJFwvUpNn4MP@^LH-7W-iBLCg#HJ-n|}>& z^avVg!``!h1kcdBCnef?qBF`qpj72Ex+syzcX#D0Wmun(t6C@}VST{ld5-#3{ zmz_;>&lhLn^zTs$gbGjbClSOD0>7pFdyEpv}Put16~Me=WB+?DAgrVJJ7S$ws?|7NA6JU1UB9e(lr$nOqAH zUPJNx1kx7r>`dZHz+aaP04KxpEQ$BW_}@juE=&l1!!$;cP-mU{8xVr%%Af};Jtb%~ zs(sFzsWf;*ny!^sxYh6hHi&B!K>6?{0=S593=9%URg4dR5ZR@vbug=|i~9WxkHRHu zxA($5<*)yvJ}KtKr#_&v@+>MqJ$GnqT`?RNX|3VgJ3W%k%xc776hcQp@N}NF z_YQmm-z_;U7Hi${E@a3i)6h?v;@<~CNtuLNA%6Sw_%EdS4_RclSfrgi^hu&1;Q``2 z6+%uVmhRD@yX6#lp&)dx;{{5H2srfdjHyaw3f^FZ!p6=-jw4V0sKn72q>pKDjZy!q zrrpxzxO8`po~{oZ^f&CeDt%0oM?}wTWk2+Vzv`b`y2F%ILh)mBU#&6~U$-bpCV{bQ z&G4%Ar5{tCYT`s^iQrv^y$Le4cA2ES{)OQFAUt$e26R+ z{c55)_GWqlDB;xZ-QT6lz$JesVU^FWX${?mO*!1{8yLw-U*asm8ur0id)c8`M@wj2 zX-A>~P-au(Nb{dmHr*w@aR!C$z+Kv*>8_M!;m$sRvD1i zV5D}98$`d7|6N44ckTa)dy?t)Iayg?VZ(IWBTYK=D(4v)o&1qPf3nuZB8%7+DF{K< zO#N^WRQzVg<7A*k;J)CMwq}hRny~IWG8IXB%s_-mIpy0E>1zMX=*gct*t99D2wk>i z2mQ49+aGNtOpKmkBk~7YnwIqAaxvy@uuuawx?PdCg!I^aR|Q%XFJI^y#$us#qH7#j zVM5!vK1?=tpD)LU(g`4g;*~e}ravFfb4YYxQqaw!JD^|6GhDy#wmScm<>p3YTRPu# zge0Q*OVI$OTrW7Jl6m{DbQN6NelHj{-M=m?p9z zauPw{4NJ8^V*sAVR0I7gF*Al>@hmy!FHiek2m;J=t=DBGvBs9-XB3kd)8}Do=dJu( zEjhY&N5!$lfykW;si@bn|E?7|Ud`%rp#OwN!iW{i2^tc`Qt1pf-Q%)=8ZIIhYdq?B zGo&ut3u#s?`3!2FU@BvinRG7(yYC%vSXhi!aOXp2jgHKl#3P2H~-{1?TvYEQ`?*Q zI-cX%-!H6v>~m0}4e!?6dD2UrMw{i5*tlB{q*w1V1UhLlpmb74!0>3kfly}IhIroWx{mc|}pQv%XAIJz5;vH_S(u0L^R z?2-6p^IlSGVXKluyz@ucFO1EOzWO0j>3nD?CdH6_b>N?7l$g z(?#yDm!j2v6J3a$0;Q!&K$$-LswoM&@A7&}CfN5eePf|P|ExcsO{M)h0gv<3$2?98 zE;2t3jxt^~ect`N-~BS;IPu||JUPA5Wdk$Lmo%?0F=wCUZ5FwpDuQ?r{z>u-K-44Xt7=Vp z9o_4JM3cj7$|YTLyBK0z zJ#~ysdQY9#5RUirj(+V940p9P!}4uoa5o>M8Wgxzn>iAVU^<%D_Y0~iy7NKht4klE&-*P#X4%_0UZB3P->7TFmetI`DZFUbdsf~nTk>K}IPJX#f-m7oT|UoYi0=5|LczK`0(Dg;^*wGMz+oYUas z8=UEcrdsDUeQyN3W2FOLzV1uv*wCOaY5tIs-p-k<90?I91)rfL!tPz?ZW@kh%=(c& zz_XPjYECQUm)n0y+wk@R$>zOF6i-!Pjt0&p?#$uR6t?qGWpI~CkL0;K9YvK)y1>^C ziOg?%Y_F=H9~cYIFqe=(VoavjP9H}vH)UvsUl%cAh%R=9!b>+6nwgf@FY*CZb?^q- z26-Zc&u`1iwf%z8T7C0l6inVd8pOucv5e9Lg#+lVHaILM7k!On3TG;VxqimMCa6&# zLEym;Qu>Rv+O#i@cn206GEdJR;~T9hh$4Vm1KCK+3U-sjh*x zFs>v<*RnTch%=oyP%iWp39wM8lUBXobWp=#G}o-cVzyH%PYrE3VR4=fI)y^7anF|c z=yBpl{I-oI5K50ti+AbsR5fl1R&ft8v$Ca{H?hA?kcH;{2kf;1IgOa~X}$rzovC5k zp`FW8R6fP@u(ni|c`&m9eVug~QG8}43MlH0i(x5=# z*dW)~(;!5=z=3Y}mUJ%hulMw3IfW84vXB?+4S9X zR`^Y=lPJEVtie11n@VVtPk4W%Hqa_42`kwc8i-R)>k~`t_kvMu$m$cr1HVh6UGkN~ z-)Wp?gkdfu!0eb@L(GNG9!>wDH+(Ej{~f@Lp$fB5Ci??9}^xmTf5|#td8nX?dV%aBRZ? z1>o5L4>lRk<73#r%9~Ww9_i9Ki+wARTGR7p0H(;R0ZQ<PqOS3T4nd<;NSx6a(cb{8wV^tbH8F~cSW^;l zlx<=wy(dtH{7GPt+Vheg>SsxWV3ducTsh-b*2%@(v&E@)R z-s`TR8*b2?8OWWpKL&z>`f`!?oP3RA6)k>U#122L^o#z!Nrt>h{Uy{}I692{Eb)$6 z7LLwZjh6WQ6UvNh#XJ3HvXI?0IbhbWidC%Cde4z73_&rLHr^jOG6zDYSRz7n;0jRz zw>}0n=-8GbO4nfqnG#x#y0qU-SwiAHVVCf?KyEca+aV>F>iKE5|0d1(z%_|1Qx$Lk3)t?M03;|1rJPe9 zz8q~8udwl+DC!7s6V;R%pfQ>bntCRhBd4NAK5!qvL3$=6B^gkH%*;W5OJu*?d=PyE zU@y&@<8h#P|6*YUh%R<3op8GnIvKJZc5x?L{ifGwERj8Ug};7{BEHb*-A@$eOjLSx zl-=(ec*A5o*O=9Q8P8%0!Rst@>>`dVQ^t;c*#MQG)|P0KKtct+#F=7T_U|-v8i*J{ zcIL`VfE(L4ihw6tvyCk2+>NU{iJ>d~}+C=8c! zA&jFM?n>%ANR^%RD)!s=GThMt?wZjBBN5d^58W?lI%=3VGmuiNvm3Fo{<;)X6@fT7 z=BWLc#NsdV)OJ$wVJ=cUx&ayZqyEAi$FAsRNsZZ_nplF*Z8+$YiQ{-Jj1{yfCH4!3 z@1g4NgJ>#5^_$syhr5o*YfXh@(Kz?zz2Ff~e7_Nb=Y5pp{$vgdCy}P=vUl@|;%?nT z(`@z3e%Bw+PUjJWJX|z4{8p_hWqPmq*6clvM(KLuWh|N0bwm93Zfs^kYed_Sg2ie; zN9kb~8m3VyEcn7ACLy`+>CM01S_h;Uyl71FNkRsJfU~z3URbG0GZr#S0k9?4y@>qx z(1|3a>HOzr_(H0^tm)^Rl*wr=oE8E^@(D?yut%pyGYA=ua~)Y8b2iU(Qfy?N;9uFb zoD$~JhVt9z1^AQrqT7{I5^yE75R18TRZH~fimBGCC!`XEOsKb|VjCmqY@4Y8IJ5m1 zr#K6xy`3?d0HZu8N0+UEMxd3P__*sS6sv=EUPFM#LmVZmg#?UB%i)a<8ii`!_>ZIk(=&wl z%{_(9j$#sO8quah^X3FTdV`bCNZMyx5ZKNaEODtNnD5!3djA7Y>(W!l4TzjP z-xc`T^kBUAb|v5DgxrAew0~p5%ohKrhRAR(x?)r{4DPe{{QR@N0$1`S3%bHRFU8V2 zrpAloy176BKpsj>VDywEeSS-m0@YYoF_PGRt%n0hXGwMf$fU2ld^^2NJX+q0*tO_d zN|CJd)%Db2tj>m*Z|0;vaTrgWj3GwRE_N0O9_0-NraR-t*Ww~8hAELb@tA|n4aN1+ z5Xw|-#8Qx#gVjyE;>98zcb23b*9K|njb^a%L_5+P zX|8gu8=S{0yaN%t*1MIrC~9ogyo%P(j872Tu(-S#p4L66HVjDNaH{Ddj#)JVuXY0# zAd%S0W^cRM(cfKv_y8eR$<$~QfvCbFv81?V5&CK=$SFzj)M)GAtazl*$_Ulm47jR< z*;fdsT3HF*{qq0RJG}`Eb<{+fKrT+mz7;yosJD`pVd2#Ehf8z?iz0c3JaEM%CLm5< z{Y@*Xk-7jsvEJ%(<3M!!W1VEw&SRs#sap#r^niLVj^a_x=+XN(P**VSwF*et;Kkf( z(=sc3MY&4*BD!rXs3&#&2SJvS@qud>M;ci0AAWNrMid_ii>BkNG&%x748Sg`=4%U_ zU@5+lZuBP|bJeoLOhU8IaM7r6{erT-nu|tP*JWmdZACS`p4l%2*5H2Yj1`N0wE~^) zaq_)+E?WlrMqF1B7gN{Vrtm~|H7KdAIwWfJO!#Dc4{2~2Xgluv-MD36+lw_WqHC%h(W~BVIJ;Kmksg`8}_)Jo^ zAP!?fsKAA$v832bMpfjSkZ#ucQ|eWlQY{r^;?ypsrrgcOLF!=ste^Eln(WMb_RGYh zIai#uuWM~`xf{uQ6M4Vv45jAYzt)noND9a*t$z(qNBMHKB$ZYdMIJE9vXc*oy$DCL zNw>p7M@mR*o6tt?BWiAfFhZp)77vC?$zGzTdA_FYU&JY|6f~%8=<3lUA!2n?y_7wi zpd(INF}WirNs1JtDye!FRGa-P`kuijuEs#O_ahb)KDhBk0#+^^NY(7ZfJy0 zRVPZedg0ngFP`J1LzaZKePV+bgJr3qO;6bm=W(e^?ii4xsX<-utj*CZYQ`8*|%FaZ3u*GY>O>$xGa;V+hx3^%wv3*&YIYX#%*_MyxebewM)?I!SFS)#XJB+_6uT zScK%K6*HKNKk7f?bA~EWD)N#&R=~m)&rvCqyd$ivtn42ihKGO4-@ez^woJdc`Z7{^ z+!}@DSgLDcjUb8zg?mMo9HnrKga=9`j)Q~t!2OCWv1|L(lhFG_<{W8pP=yC_{VUfv zaYvztpoE>apP`Osnt#O+aceOi6$)2WE@V*Q?FxFkn~F5#oNrCS-?7MLA_vpY`TUV| zCmS}s#O?C14jO;3Y&o@L*xJ;^a6I$x!ob~RMEIyrgGh&|_Ss`GH8hib06nthl3m?A;d#MC7$U&ay>?-OH=8L1QO+l3oZ9!JgJq%1M8q#~ zdnf5*oa*eR!7&q3t{6|Lq)z#Yh|Rftiq~kOB02lgA&iZze4B~va^bB>oHfrA&J!vm zTqAB(`Q{se!zfz!YUD~4@>${CSW}u-n&9_$w(^aU6y#BrOs0EzlUa-lY>{=!+LOmh zY3x-$j9;iBzKwr`|EXHdV!uA={&N9d38O3jM_&H##{Y)@qo4dw_+P@}d#?R1Ky>>* zg}%dLQ8Z|-DZIR*|9M?=XwBDZ-)5uWnA+#-6c)Oh+Y3aV{AA%YN}k4l5BmSTC$hQQ z3VF?i&ybC$bGh|uz&O@s=g#uZ~syBF^r19}C9B#t_kpXf!3?RnUJLjpmHez+gZZgRy1_SQ_dQDE4dG1)t?Y zFn&B=iRj3~)y1vO(WCIN&<(Skve@)^!uxDb21%+peef}9M9t=G76$3c69SJRIN zD#oKsZ0BrQ=OAe}vssY}zYJN$VCc@$YV64cgW+~|WLZv<$>TfN{;q15)fh}bR-XER zvE>D1jAVza-R$zn%gJ>=UaSp-e`i{Q`*sM~*d9QSv80`=bO#OtAes%zW^=u?wT_PG zUq5Z!^aZgIA7$FM*J#e(UfS=K5!h&2nF8?L;W{ISr5S2E*8j0eGC{9W<0pfOgVJpa zvXHLpTQgc2sSmQVfuswg)1$aDfA8iHPO82q1ey3FpGhd*oC)KdjX1K? za}g7(g^)1UoSD+{hRN;>sl7Tc0()3<7Bi}ye@WPGhC@K_nr>W<>}G!H6$s^I6oS&E zC=BWkAP7E&gunn1O^!BQnzR^St4ZfgWS^5uH1l$@fzie9Yb?&nF##uS!tTy*(t`3p zci5}9TGtlL*DQ;i&0!}P&jPDSEKyVZ=Jh<^_6}C-b+u5Qo);8&b@gwHM|hOAp0NyV zfJYO_o$aDs`E1RW$Z+(+pa|$1{hc(R6OA2t_U586-ZR;14X}c^*ciRmNh%Ji-g6Km z^CH7_`P1utiAwy4%~m3gcCl(jypG85IJv@bSr1pMUt4xEf4U=lP>{mjW8C60^x_i$Fr@9oLC~hL>ZP*c1K8-p9%Oe_(QhNlXt3+JN^X&oM}zv_Cw_U$D(eJdXoaBb zdS+mgELO3cq?r}9TZ(n(dVZ;+794E6f_jLGjDA)~DJS72Zem#a_Y4wVZv={EI#Q)% zl7(f4`o9s+StXVft~gsABSxPMjtSJcg-(B<)M^O{J7M%y2_j(8{}&(`M_i&+7+Rr+ z!o@~A6#QaiS8x!SuyzX#_0Lwq;jVP)Ok;P^r}sUdIgv-c{&Xga^M_~q-6@;`4jXBT z9j5NX<$V+%`G%)w)4pc-fr9r+CF6c2&C0`T{JUHyuM@E}g7NFg#u1FxQRz^=x}?IL!K>ajtP3eb;ShXaew z=ilKC%76zPd%M#mcbK5X*Urxr36)3@U1Yq7BzcCZ8;))cWxMnSSlF~!p~hsKOG6$LAjD|BR}F)=H+Dpp$PjQZsW`~!d94tT1;3zIs| z9_*2Zmm>{zFsIe3_B|f8vim)iL6P}CN#AzlScPbW6-igSbr#lB2Ut96nhI($lV!Gl zL@sDkKJ8J5Rz~y3ulMKjNGx*QQ)5m3cvH@!c24%tu89cbl-R{^J6}~61byf0jWikl*P8)eGhb>txFm|(m_g}o;#Sy{w z9K6=)%Na8sm2}HdXHg0Sx9jaDky$*tuYNv+CpbLC%32cRL8cAk1Snh`3OahDR#d$a z$G0Uhx-XJk9Mw#wH7FI;LPuj>QZJP%cOSU7Abw>}_;bZOY;rk8f)sC6rI-4_&Zy6M z7yDrl4izO!4y>y9o>gF8MBIF2TC-JABd+KC+d9OMkH;3jk-@32Uy8)T`l?aH<3MBM z%5YE`Qq^OwP8=g>dlT9|uc8Dcb}r+-V%ngo1@mWBIY4H$QKf_Hhf(vs77Gk`r0xQa)Ol+9h#T&m%sVIf;6OwThu*A%9_gO5dUgr$o7Vc<7 zMf#pGgpiwY#Z~*ATeAxcK}uirrNN;wGD^siAWtjYj-xXfU+ZmN(lMR=_=xcqWm;*J zF6h(q_(L>`X4Kcnv%X1KN8p)Z-&b_|nqbEoJ|pYOBet9*Z14|z-)bdv7hy=Xn!u-< z=i-KJAdH(fB6L|oshrfI;Fy&kuQ3QoIemzttM7UqK0;e7R1vN8TeGhFCbHjR)LpoW zdx9^vY(ZavnyUEUyBM={Qnt2;G<^{JaHE8P$EwfrxPV1v(#_1S1on$ceua!cx89Pa zTv%w-cpIVZsruiUZI+Xh|BSG-ZLK%_yG&RN#%cc$pKar7HMC9F8qlAiSN=)rZqETq zqT>ME!Z0><14@Oa+pokftBwQ^aE24)m#Xcim0A~Pk9~w?PaT0WY^Jp2dTU)8!$4rQ zwEM%^fZ)?ccbx@{*W8#P)cQTH^KX}RC# zlnMb^qj)}w+;OxhYG2fu4C(%*H1l1anE4j{L6c)u7ZmFJhaH@8!Z^1#(v?529szFxBslaNrw8JcgEVr}|^RZK?YNh_z@B>gLx z*UsXa;m2*z?3LBcU$T--gF?7m7xFA@b;b3uxh0{XIQq)`sqNHi_gk5CZEzY4oE60g z?R#%#;mb4`hIL3pX}VZ=%$?q|1XHXnr*?|QFG+=K*Y#mFGjg$D0z3-*CNNsC zzWNy_cy%WtENl^VfFs z#2kju$iTUa!QAi8DBevV)VvSz+zP9noh|ay^os;>}#7XC%Gv^ z@vF=C&qjU&NP5P}VaIJ)aCiq zo#Gb6T6ffC|FI7CR9A-x=eGmM-@!MaOnFX-ErSZyuOh=oRruSj`zVZmlRQmjfx;oJ z7Jgx$14a9~p%Xn2e`#u!d*`ZC{^vTd+GCBd1ey=-yY!`yPA1oaFx!7AKzLKqR+UM$ zv5F35Oh5^g)vTf10pZvoec?p|*vUd8J^qLQXvPg%OQR`9<;6;*W!vJQ70iFi)_*wI zwkMotp|s9mGF|G^7A;o$vcP9dDcqUXaQE!$PEcGJA8#HBcKQzC=H`gz`T5S+v)vO4 zL>Tk!e<1U>IHneB%B>|t$<^~Nr9!*@N6P?4A4$d^!1uOT)qNBO#l2sGGhbhuOk|<^ zzvz6M)=tFp_C8dD=V||k!Ul|}i`S6g6tHOQm}y;!J55g>l7&KAFzyfUh&@F&ayj@%F88B}~V2lxIG;9cME(BXfWi9G$qhC?L+{@MYkmsR7 zi5O=}z)qXGwBKSU}jh*9))5E6H5@&t^=;Z!B(# z{jFJzJD5nB$N962Me&_>Vg@_)n0yKcnO;Kxda#w=XfX@TPW1cz3{5 ze28%^cUx-a@4Iw|!;JV}Ashfm*^gyc4={XR#?N|?s+~?=yzD8-{h=pJuc13s70zc* z{Q9jAtvo~5Bu2(3TmYhGmzFjyt0#Z}5Z%ggCudv!H}z{nE6I92Y!o9rkCJ+yDg+uY z-L2{?ASs~ANU|_8pnLeswPF;8M zOKFvRcQ4njXGt&ZZ=k6=rMicAm+(nxI|D~7(`=D?)#$Llezte=PCXF**sxXS@gn|b z?=kzd$WX(UD1fbTlQ!VlWJ{Dip%qo$Sd~W^{{4&2otuV>(d;SZsd1z2AfH@g1+RFm z%M~8CqMnCjp2_I6D_;iHIVqji7wRa?Cv;}=Q98lkX}4a{XQ4n%dc27m*-C>`L5`V+ z)1?-R@s|ClX1^eAV8yY^J@1>$W{V4`RTNiH9e!5y0n;B$&)fA(N5HF5ROhoedk`Cs zPA2>uL80Xr3+COt{chYDXs!vA>unTt{fQH^pv@;nB7i`cV0|D31ATTRTfnoTY=#&0 zor-tM`VpSPV;@K zEE8+`?F)-5-;I(^`zL0n_nvjp0rY6s3yz^K!+vnObwP4l=;u+V#K0s3T-$}BeMnZ1 zIM7%*5>mi$QB&6Th?W3_VoS8nxXN}Wz+nXNL;!rbw`1OeW7``cLf&e}ArXz--S^A1 z)ow`q-)3O*=JMOR{E{tKA>HeaO|Ek~yxT9ragA_A{s2gXk0GXrDzwJ23q}=6FIWTI z?_keC@;T`|Fsgi5vfrQcfAcN%I0HSyIxyWG5$SPOvfiDzkp%Qk=~_Nt4Y5|6SahPH z$`=pc7?R?6x54QFZoiVFcpza8uOFfl=iS>g9Cp;`GiPpqx)aJ?) zH4yz*by|N28K#U^jLJMN*3WDq+vSQI%cca5n+zjc?&MPLucQPy2`}Dk+%sL7r1laS zc4Qg+*WBOQR~-fg?HS7OJb|p+?5QGjJgA5Q-CHSo?kff<&l{cKdKp5WN1@41JLH<; zjqX5tgd(@?F{z=k6H~{7Dpl+4OCssxGn<~*Fq#%iPY$jxFypPo=WYx0_o02edWY-RM*7M6px!N-DC2A8nY!JkE7y@w zwRXBgWphw^(|X?uhNnMz`lNI;K=R7jsfR@YhrHsD5kNZ2b#EYKpy?Sla*e~fWRVs; zv?q`ANk}BS5(32#S%`^bNy0sX1xQ7JsW|X=%4ZtGF9r+Aw)8G1frEE zFL{9qU-29w>uR{jl`)V1{*kuRJn8qAhD~wR^lgk66Bn1q5s?3kwB?LQP>76Q@>P=F z)R*95*El0;|CszkT_%u%6ozy}_WL(}i9kfka!{yf@|6RH&BIT14jOnm_pWFw!|`PY5Sy8AU{_@Y-{7F8fsBc_lH$xGLgP z!gu{KS@?Bdyg3WffCI&+;A4uV5_j0T2wxXSuf|JzH78=6$4 zA=>%ChmiWo`^({TDZ_6m3WMe3QHB4zh-Cl&2SGgl^9ZW&{{;t#7`IxgB<04I%qWlJ zvef*NgiTJ1U)AX1A|hM@zWSJ73$E=7ce6$6Flpr?-+%fRevvf>y4m?k9NB)N(B8T% zZkI49nimW$UtYZI9~_|6cUMpn%AJAAX({;&)spz&xw*Lk*IVnnVdRoG*1{Qa9ER&i zm@>T17Nj^kQKG=jMa9oqf{&~*!w)42&h!?8nJh`v=zkJ1S>~tAh;7)fK|qU(kC@3M zbPmA{Hs-0(iw*U|Ef@sn&rgi+>xoE>uWHaL=8L=YL^(3`$&Z`WTYH&kBW*ryX~Q)g-2YAc^HW9lpP;vSv;LBKqW#;LW$8=dRR?is>%OWt!{TL? z+tZoTPnK-NE_Lyh3el7{Ws>S+{UQc>Fxn#N4_xWF6`mu(&6U$1Yi_5B#;%AnrWV!a z6M4$Uq^veF{&gAD6p3}R;zoU~JU?pN5Ox=tME3>`LZ&wLc7Y>(SK1P0oAkTs1vQ2% zNA0%n&|94-4ftbcny^cmj?m-FO~XiC;5WvJ_We&DQc+3UxcXm2E1!&&+Y@=KcQo z!sD=)d8)q7SI(e9Df#6Q1g+LN1`-*J2P=I!!vQnUS#4_iqX+Q4uN%M~TrswO+yUb8t9XXDH%%5nLc^ z+7T%-33xF5FJE=dzD9c#oJ@4-_#AhRuZ+&ryI{v6@yjsFOyHfGTL!ivs*O8<%lXcR*)Cq!Qb_RwZNa_Uwf zAZ^uhpwQ!jL`9l^zP@$@r?)NEq_%KpNpeAdQbEo-OuSV*K7vT^o$qPbE@>koGb%Rk zq@Eo&-kSqe_x?8N9uBQy4(fX0w>Bb-#U2m*eYWuC2Dn|}ji2L7owkS7a7YOay6h%F z7uRi%B=)x;;dfkFJLAwD+Zmz%{3erIKKImH3BAjjSN#N%-FUV(i(A~(O?BU^%W&UZT~Ozc*rkB~yfZmWtIZ5(_%iz9Mv z90<1P)%^M7hQqbvvihQopZsm88j-PnyOV}ZilC3Lc1M*_p9eZ?*}8vup#{z$7j*k8_Ma1@A={6D(hF}jlP3mfcocWm3X ztxnQ$Zql*cv2EM7ZQHhO+sTbR>EHXGHEY(I`EplPom*$!s(bd{XFtydk4b&bT@aBq zvnrPO?hPRu^R3y)#7ZPTGi<5j^*p*kI&U_noe_<}CF2i_B8#Kgq$b;uk&Kf$z7%-= zXS_0ba@YCz8{L`-=O2P*ok#R#OfEXVp%>{j2iECCZ5lf=s0-1U9F5Um42|6%X57;! zHN~M&mJ`clHdp)*2zoznxE8IPwF~Y zfUyc3*Go3M=hwCH)>pj!B&#~T75L%w$=3&7N*kP@`QH%>*rvuvqCD4OW3~`AJ?f6w9#^+wvwZx!q)2`|$FKW`0duL-z9+LPlB+UR1rw|` zFO5j2{$~xoLSB5{PezZ)j_8^npks@P-nf4O22e*?ULZp9&@LXTuv&Q^#_Vr6cAJXf zU5CHXq(3n7o@4e0*^R~vjHaa&ZYS0+H0&-Ma7wHUEn7B@8$*|n!;}#7=jn*~SVPL` za49M2^6XteXjlKrH(QfdL>>QHQaTC8jvwfcg6{q?GoPq2-A0KUYr*E<9)6N8P$XS} znOUkk(mLBH^qOdIp4wZoVVlh7#Lo9aZ_Qz+{4#~*F@d42JKG{O>QGgDJViU0oip(M zOrqq5%=&jCeAJL+adtdzW74GslGnq^-h0T?`%V3%bgxF8k4wpgaZ>S2xe#*amRbnm z&x|&q6WdFK@i!=@!y;pY7KL?B{`z>21hgP)R zJa8wPGkdQANmgBH24h@laf~Ws=0R{!lb~8#{#h`z}#b(}!wKX^m3s#i;6$}ud#5rP|X-~8;;ND0i z@wkYm;n6i)-PtbeJB07{Yo+-yRqNa5^*kfFs{4qsGf|t zjP)^d70YgjtSK z>uDF6@}yN9;5$+LKyi!lCLUaAn0^_SO%vr*4)C*GoBxw3JkryP|S z3!qh4l7gK5xo6r3byrI_ll+(aB{E7rs6;B5Ib#X%^1QDGN=t$xk8{npliV%OF)JFZ zmCS8nei*Gf4{lS}qjy)ql4g-J-mj*8UmNwM1)^TSyJQF}uj>#XyVz3jYlZ%yg zn>Ahybomi%CqB`dLweZ}g(J7~D~G)jJaSMtnop=-6$%|wdoq7{DkPWA`(%_0KKCC1c7T+{6;KaxPw-Hh+LS<|i_JjRbm zow*`xN-?t$-xgsFhBxDCV9jGA%9AlJ_TlZDM5_s^yZotjL~)s}N`oWwbB3*IW4?D> z)QQYIRrdP9>ks?m7DLDVpRL5jPm>iOF%cBtKt9oD%4_ zC!xVCQDYFDn2)TYC73b{re`g3#7xt9Xm z{20?ZI)4#eTEB7n*4lA1<3rzeP4rEgDsB>X*7A56E;WZZ-;bEp9; z*IMZpzbw7skX(svEU2eW*D5O7y?J!;tlUc1xR;9!hDDcn;fsI~82L+Y4XJPXTZn-q zPhjKKd&!w%n)BPldInbr_7+Y5n&&roINRpucb;~Uy=C0NaI7BQCXJ%Y5qVGc9qVK^ zi&d84R*j{BMvFJk&rds8?iBk^_VAZA4y4=!cg721s4-K*0&eB>t)LOBah>yK`5Uz| zXJAN%hSQq$dTon)+E#j8>(8g9wXx_xK)3i_xy@1bL|X6pVjgGboBXDx#_u>KOn)T> zn@P>R;^cdxt14;3aeq6=sjSIyMd4;5pB;3qUv2(E@*DrhnS0)n#*BSggiNX+l~prZ zTAEZvgc`?C8@MVdFE1d_*}W&9Ubp%Nr|ySU@ve6@Vx%sM8o4A{xT8bz%7{L--!nR? zvA~O_w`-EH3yR~I#+CAV|7TeiDSObTejJ5~2TeUxPz;dg;z*>W<7PuDOF+w-zLJgIEJq5;C%nk|q@Cq-VAv`KSBG52!xryp~k?FNcuu z%3J86tJIWpd2W2$wo~)MXxZEGQ_}VY@a`pu*Ud`rF1Z=`Ep^d4%VpnNgHDQemyE({_`L?*bry4eMr;Xm}ftT@U#ht>Dl>_IF3j9#`$aZV*E(3Rd zjuAb5VLz_`Rls7-BD_8^<$inVz{i0kl*HqEck8cmPOWFmoB6Z_4N|7O?qwaqkG)@2 zg;_{e=qh^W$O!wwiKlII}c+dBR>*z~+%LJSUKg zLSD$Rez?dneLcWi+eAl52j>OHeaEK^sFxg-1@^PiJPUrs+=5fVe<0^)h#Go(8=F+S z=t2{le=OEWs$5v1rvq{9Kg=lMRH2duD-vs{vOdOZAaDcxy=rXuSNj^xstI9w`Bu!dGBs`Q5f` zhXRTZKK@!D;Oiq4L2E1(ke%wZZr>b}xU%M-$zeKLBajT5B+H5mh6kRBndZUGymVvW z=OKr1HA5ijJMerrC1xW8*K_9zyx6_L6k97CYtpi=$4)nP+$~({`W{zxtSXx~>xx$yyJsgkGu{ znH4jQ8L5|#O=+b0C1}Vb`gFFdD=5u{A0>4#X8_`UB7P^%#lTj;BB>9wxslSwCZi=z zyh5|k_q5=ViJH_k(?VUTh}9J$OrnozcX4xbFB2-Sh{%23y|$v~5n_to++D>Y%s*dcluP%3`|}g_osG~&2J?zX zistk?=Z|B>_r|B4Uwqd;z~_J=&!qfGmszyxmVhPWb+GYhpCtpn=;(Gh5+ZO<4rQM4 zL6TFhH``Mc=VY$MKzgG2yT&voEF4`2g}nNZ66-6CHqI~&widx z23D9UOFZr((7p({#-E@b>c{PA^fwrIvwHT?u^Ec7e6;qP<90I+e&NkO+%{YT{t9bR zLMtUs>;HBde3af!$SWa1wT!ooguh0I{PW(=uBs|r#>ru*(yVt_xr9u0B zKP}l(lE0AX(e4aY{&6BsI;K+;igbS^AH7%W$!O(W_K#6|3rPrj_9LQ*&7sV7FH$O= zmXZ~6QwgG*;%aWqclGVtb1(fXPkem`0!U!a#+~GqXYYKjs zS!vxZf>RI0sN9c%Zm-Z=i&HXYv}%>7A~>I>DQGI|wK$A90Q$;&Oe9%^a1aD(*MyH> zxq`$X6ZdxImc~yOKr4xDSbd5>)Obm)kGg*bHr>DP+V$=*Sgh8YL|;ugXqvALUOkp& zJQIwLjw;wS=Dro({&MH3Jj*?E^@|rzG>Az~ek$55EG3iSY_F8?=g3CCpyxd*5)e1` z<+T}_1|1Y2%B_UK0>FW)XZdi>A!Y>z|03V3f*d zesNp>(OdsM|KA6rS!rclIV?EOS%Lch5Zf`{r`a+n!7A1kmk^7ZJI?q*+=c!VW#Q*{Kk*`aKj{t3t2e4XgX1xo_gd zrpUswp32f3!(er+Ng0bRo<72|N)ml&_97eCOL$SHy-HuR3N3sA*uXaAR;Mj5B1QLy z4b$o}XMG?E-}Am*;zhCE(r$@V$^&SY=N(*`MB~lJ4;lII@slozjH~Yp5(VD)8b3`lTW^XQKQHCRvC@VChzGJj*o(O~cla2W@g5EYF0tNh+ zB9Bl}1)w0)a{6)ns70bq_Sjh$m_~mt5L>u213K#fsZM8% zRg>C3V{gAixC^DngM}9r5$$kG4Xrs;1oGYAKls4Aa0d}-Vmc-${Uar1M3H<_nN>$*p%FNAuz7a1B*iIwa-H64uvLkOvkl%*^x zq?ZcUJKf{RP(ZL)q@Tvt_%lnC*tEs@%#28Lh*Yo54cssOoxJ! zoDBvFPsO<1q}V%3JU_jw!r<~|rbcWb%(=o0*;}jBxsUCrA9BN;XsXFKvINuVryvLK z(BqD&X#Qm;sDRQ|V}h(4yQBBg0j6Rxtv2qS$W|~qTJFWN^0iv252V6*xRh(MCUvzT zrX-3e18n^(ku@5l)7S^HLCcCG0EazxT)L>|Iua2XIpSyuMuy+*){ZNV*Fi+NqUwrX#Lwmh^k2~UUWaXHnL7UqF4~TJ9AC408JbAOHvkqtu!*xg) zX*=iZ+lqB}TjYAaLWEM%vE4_#cV?U19{Os&mzE#a&`h$Snu5SD6V zg->kmoLIk=2e%zxyJEs7E+N2sTu zqcfD3FyreiN1f66xfJVWHm5O^<2EH-Pq{pKmSpb>FtZ``CQy-=w#?FhDi7%SqI;Px z0}<~Y&v$)AJ@;q#_FNnsXp7jWb&cA{P_D`Yp9mN|`4Pqr&~f{=kYMoA+WqxXOGm*A zWNV=Kx{qym=|5OwLhwB$Bdrub)G4N!TvNyYVcF%Zsy{o0~y|GVK}XqK;l$;BSEic_%iK@znV4sp>%u+vjIi z2lb5POwQNB<#0Ida_cn~{+557yej$|HDQE?YA!mI4^s;XHJd64imOsl1PS~DDMR-}n=R7c^(3T^+|*S2HeDt;_AXI|0tU-{;l1?0Z~5yz?{6gELH^4SB@CYD zD9F^`33!T9tIFIIIH#+ z0?d6qwN$Dd1-@ljPhdM7@#OW{1I3Ow_YY_@XTi{NR>!w(_6G;d=#Mg^zL{ZSG9>a zqR)Ms*w|@gy70plJ@J~Fh@&-PgOESGp6r_Z>~iEmP& zM;(&|w_Vw*Yk^Bz(-hhofah^}jPc_hg-q z49PszgJTe6HXkLOcgxIFXV2OAx<2hQyFN%>Hr`P;v+Sps%H&*{jPYV#Cl6x1Dnwjg zYs@cHVMdz6$R!hJ94}wZLyu|FTY?XV9*O|c=!1^%*Q?6v%Qz?Emy>SIz=VW!n>F?J zbs%jl{R_&f_E0(a&!V>jw~yz$2ogn9UDsG~+N_}hr}m!9`LUH&;P*q1>mMKIFrA+J zaE7mtb7ro&4k4l&leZYS5Wa?a8-+Y(mFalrbCa%JKc$%no{Mq&N4KSi4*X(On&>cS?Zk zmY&vFP2c{o>b`knE=pUq1c%2V_Eu><0Z9*e z0q~`LWHG^gki;+6v)&spUS`yy+8deD9j(`vRcsn>)3&ZoSE6Orx7u6SMJHI4CMi-J zR}`&JpgTG!LuSm;_wH(Q>F9AV5*1j5dllu*j;N zxW-$iRW?>gq%T?;IhuRFNK>J-FfHv9BZN@2Jj~&?G%|FM+OX?-LDHV8!ST=qNs7qw z$#f^P;`c;JeT!)nj~(!s%E@937@Z-nJqcUmyd4pre>-Ct^pcOz=f?SbGUBn>G^cx- z)qQuux7h&D$IZU7XyL|B9);!l_p;h^U(wlmIs1?C5BHUoc^6T5al`=1L+^G622axs{3;uf{*|~$D<2lg>tuZ0x;n9w_34Ey=xBy*boCo=#74aJq5g-QJf2ES zGqF@O%9r9H;g<=M6Efqy0P$S6)M6{t8H9xbjhEkeG5s)^9Q-k>7m6JBtTv69W1K?< zwrl#@au|qr{59yxhDkOn@!!q+w!?Qs5$R};5=Y7?lcr66DX*r{9m?=^UW~r1vV~k+ zmh6;SEiS-XaxuoWWj@E=3nUd@a9{5)H^Q7;>Izf92|>M(u4t%6j~v**>FIE1y1Bt^ zk&(_a%|-pxXtLCtO=vEa{r#Nb5{dI`rJOcc16*%5Ix^ej@=qlXV+6Q%+N9=E#IIJz z4FgjL4B|;i2bSMXIW;0J4@2ueLG4YR}q`_T5(j0Gjo%5kR z+P?B-nI1Y`{WZG+wio!7Y<=vl^Kn@n22(L_CgR9AA9=T`7rovrDt9yyqNZK)&8D7Z zmoQT~3H@5o2yl@>Mct})HlYo;A=!jKIK-8D5-(WV{b`M%0r-8pn#pMs4b!VLCG z{ATu{WX~Ib9Tn@&Vn%GtMI2?Ny~H@3K%-TSN=zK^Mbr|7JMXetk$zRpcFw|L5q!>w zRQ-gsf9M!E!^aB8U=Cb%0IiosKBoM^*)nEi9(m027=HsCqu+C_bpggEnm^~@0Dh%6 zq={Q~3YeySJhj6rb1HhVK;#;dej$RfcFwx1W6MIBhoMVZ1 zmcol#Zu(a=`1coHtVyG5V`Vnof~?BiRSfrys*qAg5|Pq8iz50qABeUlyl<_NyKD6D zJb60!?*H>cKtJ8uI{3foOP@@v!v$cjwnWVCeYlS2RSBZEgn+b zZ)2vMosw(^w>)lU*DQV6e{U30?>a(1EIiy<9k3K}1ZH)c6={YJmg!btwS*3CF#N|z z8)Ub;7&I;`+k(MCL8r*DIwp+MuyiQipH=8905Q+Zpeh?D~g@&BCe+NGLljO_`Tq z5RsMnn|}%Ns1-v6gYHk6o(6bZXNMjHX#c5&%4ff?{-E**BB>Wca>JkC_ZR9{vcL@m z2JtChdFUwH?x8+1=^fbZea3aE3vR(PQ;KR2|wXXd}Muc?ZD$R+Hi-3#UEvIV_S3W}Z zT{r{4pqZ8}+D%zJGP*T^ zS9x9wa;c#T%G~7<+e|<>{@xXj94IYcWp6bx$N37`H4&|m!qskuNTUumTaAeEYkR-k5>-`AN!<{U{qTEw za@JT9Uh?u72tR98`O3`E%l%?nS_Xei5YQD?7NwqX{q99HFBv3}Bdn6nmC3tDR{wKU zE}|h%ywiz1$62wbivW~p0*)9)%)G08=yt(>Q5?bn?OS~Ut$!e_aZSz4yjm4xQ3`ra z7$NH=lu5(CXcjAkukXlua%*{kdAyr)e}Q2vEUa?Xwh-b(iINour8iJtVAp@JbX4!% zz-^DihN_1HcP-3KnO|mnY&ETy<5@R7trbMfJNcf%*G&It70}=10-yHyoGE;%rmtM8 zpGbX2x+-lP;eK#qL?ff(N3Ss@3TxnWG5|^49#zbr7l9u?e&TD)2*CDyg-rg7zoht< zcp+!j>Ms>SOe$8)dX~7L21GZ#Bf(WOy16@cOpiIeg!Y$1V#77fr9&JmSS_upS+jCk zc7#*|dT(~Rr3Ql{FycNa468Ou&R3}8HE5XT!Zj>*K8CH*MN&fw<4U2M<~f|j$RRPE zWgc;TIT}I{CrA#bAF0I$NDUf-g?8Or#%r&cZC;M7k8W4Ic29vuNPk~oynZEGj61HR3b|0 zqJXL0$VVs@4Ps6^F}F#p$`~9o2K_~}%dxQN_{4EZqy6d8XoYz27S~hh)p&r;t0F&s=N@M}dTHfN=rQbSQ@$ zSJ89h>io3NLe|-uNXa^#<6)j2JXE)}FqhQAbKi-=%;N`rtVk|32EwiNhiCeR0ZXXIfa9{^NG&(J(0Z#BIx*;%N&;Y zF+%E5B&Uqm*owm#@Eu41ye&P#nHqC(*E){K(U854u`@8bNF|kE4v?KJ^Yv5$Bi^i` z`7yhb!+D0o`-<|&Q4wbLy0g~g!V)-NKOAI-C8knc&40=yzE6V!xY}hF+Q79b%@p;~q(BMJoh51v2KP7DSui5VD+`N0KWDVB^Ep&Rx-&Z!7-$HprBFLJ;1D>|KxoXy%I z$pIuxsZtzbx?jLhIwtnRf_eFR!;#DaW^buy-i))rOSDpQbK?E%XH=xcV4YbW&KkGh z{%i(9qqBL%7%@8AUWuY?Bp+4W{5IXgm5)w5XtI7ozV?7&C`)o8`azJqe2b+VW9uI9 zt{00;Yd-X8Kw#Bz%y0yFp@Q`l!StEDi}(jG68M<1;O=2y7n!0kXy6gI@&|{EkJ$(K zi?s45HD4lmhn>#s0uRtg93kF_+8n3~Sg=nQua=JWqsf$4h6M_TIM^GY!r~cLz=fG1Wa?2?6bOA za6^638vR+if~>3geuJcV!WRfYgA2?l(6b7}H9OOLMs+)ANR&$X7WRyxH|+aNIek(4 zy@+8M0^rIJOmQ6~*uK9igwx^HN^wb|6ZeTHsHxjk@OB7CS6`qpbhLE-e^R+-O)QNZN9ZA1yL2JW7?asRm# zjiNl?p7P$q}26N$QoPyWMn<@n%b^t-_L^3Busi__^{%9mUg)7-xCO|3CsRm6U)E|XY3G@RyB z{Hj_i$R6DwtPpB0r>}`4ntg`V=4d{Ygf_IOxF&wJ4%)#Ibpe8nj^BdIOrfD1Oi6<0 z#g?)97tzevzbsU>%?6#Q1aiZ+?nW$3GeCV=2~AcJ5G1zj=wkXCZOT)-$7{w#>3Kq^ zIbX9h=wm{EVerpaE=OGu&&lr-_gUu;o2~4QE)3^eGnk)=9bB2%Mftb$;|9q!AyS>^ z#)TE-Tsovm?pB*AwzOAUw=f1Kyw#3J`tV=qwPD8;wO#00;XNXjEOVMDUwSt;`uaE* zAwsjx-61|Z>rqY-A7N?JPQ~L86Wf=z_S#VtDR;}@L)I!#+P@ypH*2XpOV}^S>b|2y zemV^}QjyKEasR|fw2E(kI_xv|ddEbY3CQC?MTtkvGrZ$r!9kB4y}4Ob5FXT?n8fxKUzD$y}axW8AaO~;*eq_ z+g5Yo^R<5yAsg~uHqbdTSG9=z~V$c9FSMpNhGo`>V%S|lkV{Vpd#_^~-N4J1@C0QQY1HsICy{`rqboHg$Z79Bzt93neb;$Z znoOvr3qZB1@a1J?pCT#-B3cMJW1n-6sVN|-#5MDbbWV{{%iM}Ua2s5jm&>;li1Fi$>7Ln0Ys{wCh&X*8aSe< zHQ&F3-fcZ{!cTH_i_T^3gmrXov9|u1W=gV|FdGmBRp+;>W&Ze4yIZvK=9J}p79)p%ltaCUpOTSthxE?ukw%yb? z);bhcqaAu`j|96*dMGls3ydy|~kVf7|&-6R-&(+6RiEIZoPspt;sug=H8 zb?o~--w#yxf3lHaG+#$LEw>!1Cjzj2L5}4f6Z&!Y*2)Oy=mCX#9v2#_GEJmWd9f*ed}_%a2C0}&<8SK?QnAW zOc_dTAF5yOt!WMnO{mqI5IS|foP{jKac7NGjSra#u5_~UaPCNpMBxs#nb}5VO=gNNdEKSbdl_S_H~TaTnY zeLr`9o(>52JZHRA+lWK2lrWrKP`19nKErsP)ChD8=eevufi~}<&hivenq=E-r-&G- zQ_@pHvsr?^<*ax5(Z<)()Yt|)AUWxSqj9+N+5wf!x{n&al;Oeq7iV~=Ciq*CRb#LApg}OcblE<+@PpS6sdYk`Y3AO4$$ib#bR&+v5Kto(F2J5bHsZ8&; zA;I8JJ}VQtDcLVd}$yJ5H5=%FIq0N%e6t+Si^kFKA&qr4^N3T;V z>97(jN|$L2DMwjZgJD+Ve`Pvf$0>_}6YFlb>Nw@sI4$FosvME$iB7-|x2bBI2XzG; z%5o+&kGA0RyIuaup^hx^srNu#6us}P65sT*JpAA{T~fzO9;<72lk41OEPG!zr_`_7 zMT0^I%17z=k?4}V7&7Ah8gkm4ueh+b*utXtGbhsKt_n99d8=Q{Y+X!8acCZhN;S!J zCBV@Y9#^JSuGPna-UH&xO2-l{CA1)|sk(pl{l}97~^?!=O-9ziqzQcWOVRYGhU*CM{%bbVnsbiMu>BLplaeWw* zeSbS^?%F;L;Y|mrUWlv-gw9)O$=>=0Wb~t_%N^WUz!JVa_ijcKlLpOse zuPaGH_4M_Jjl0}Ml)Q8EkR_Xfc27mg;}5!g{LT$)S`2It7yRl0NZ>T3eByBNGthxw z4KaIF-+BK;RF&J~=SS;S_+Z9Vi5qK@o;HszNH+Vq^bSy)b9q42()tJ3m_sW9IsJYP zg$fOprxhl5QxXW45_NJWl#=Hst~|Pzu<7i9f)(|UQSuOb=N|?8G|#tGR_YggI62U= ziI+ALDy6fTC5JRrsp{iJeWj|Lwa7}T1)Ag48$SmX2hE2<1S1MB0A)e}Xnw_-s6yIK zae?HBqA9V@ma0_C2}o8@`gY?w(;bwOJ4>7cEq0u9vu$auKN9QKFk=a z?$D!*?2aHBYg^8<>=I#{M+N#=i=5D1a0Nz zyRTENwib7+K3Es!DH`|Tgh#cx8peH4rZCwzQ zau<501Q0qD(%b>m?@Tna$BiL5KiRgl%==qFt!Z$D^2FKIR1S$Dbd!gIh)dvzq_&9d zRkh`|t9>btrTDe+=E3^$t)Ko*hXqeVYf3Y;bIy%rPfttvAl_WjMuxZ|p*N^NZ;vpC9Lf!OY&e>14Fi z0Aw>2k5m>5&%d#iR8MDW!){}tHvv>SSn-^i-{KQoAS?>?@wVu>L0Sa_KuLn;+1}(( z4&bi}ev(hKw5(vZZAr~>%zE#~%Kk{M`O7(yL{d}JKN+9>>xEuJ^Hpzw3yeprr=zd6 z=Jowm{N#qF&P!9hDL;PhUR+j5*ud7JS+y6dO))j#*f89w9xG_f{eu z2`h2f`vIzde?7@&Qtu#biup#PkAl~aK<(+|(2X=slm(xBU)u91<0;_^+^<}ur1GtL zpWf+3mI`HT6sWmId5ncbv_z@SO%q$$qt6RtTDm0Ypgj^8=Az>Mp&=wx#K`X|yRET* zsdxTZqM~cD`7;bLqVE>^8N%j?2g3>01j*8qMOJ(FE##5O*3|g__SfecTXrJ|+qH#1 zf@w>!(|4#2K*B_hx-2CrwILo2g`%LN>NSZXC2Y6Ee^taH6+y&C^*yCF)K()4P^9vx z#1xXN4mET@(4ddhQNLgV9bzS;VuNc_>?x-tvG76@CY4r$!4N+p;ntm?Cuk;1OV5?t zb%WX86%nwF&NOiETs2%NEzO*@o8=G59cqZ}6p{9c>HDhtzxTMrhK9I{GCVa^Bpydb zZlD>cyM6l%0SCudkaeQXJLph~cS}V}OUlEe(P=OE23j=tRdCRlJjV&9kKx+2!{6UO zeZhcAW(kt;gHyTUbLUJDf{;+=za6aDF!I-lKLQf)Yq$1w;Lr1Q^8XAAX8w0II-2~y zt0eo7{|!UB{CAV!|Azl}(+#}da~V3XBA%LtJE*b)AfJajf*$SjSBNAO0OCe8l!&+w zim)FO5hx{e@D{XXxIpXy;+HYD`FCd*Kfm7}fqdVw&8BTb4uPC&M$J^Fq0mG^7vhGW zkAB6xvF>5{buQWnHp*!AcvGj@`6e=Eo!^OBMQZx|m|SgC!FbUN109X#V4;Hb7!BIb zsx|Cmq(|kZW~Zpdy_yAf!P014XiwbZ)y0!ezJ>x}qYxe*7DjJ0o<6wi!!x$AeFWG& z=2EQ*!px`cw`DZ_cUVR3+%?MpT;LSipW}VmoXywQH=jT{ySh3$H#kB;#mi}GlX7Ree#^&B)4y(b;)>Kp+QGRb31QmEqx!f7UY-T(t3r_HOB^s zC0urPR9TLB!|7VAfSL+cnVCpErB&=Tt;L~Fc0tp1cOHqjzvMCziU>;zUe54wS~6(+ zKR=S|)GCY|IMb_l0*YnsEZGJ2OXpB_cQ1jqZ$1uG)B`d&l8m@f`5av!h&}%w>=mLS*F8@N&(`2Aoowy zRzPE>F@`4%v4EG5{#HM9+fry3#Aj5W zPMNXk0xd%7j~!{FqQvq=n<7=*$qr+-k9#Uu$cB}nybITsUw6X%9os@g@yz?1v@Q33 zBTCnql&3o*Z+<)?8`Tjo{wFKDR)IWy@R~y%lc|_X2tP4cIW?Sa#10QmRTM#aIS|TR zeboyK#rsM7PU?ZOmTo{{GQNAL+fpP<%!47bMWqC={s!F#C*~F4LQ>EkcB&aO=Z#P^ zo*rj|bqE%ogSRXC0 zU(YPc(X4j4qDdDh&M_#_t4)A31(qhCPuE>s!`*fnm3$qU?7~73EG>u|`s3C} zlhoqU@Hy*R3RewkIv%#W-oQETgJlygWFR8;X{|R!UBlv(UsRO=L5xxN&`l|U5{v48fy(Y5-K|3Y7Yl#yI zEr4=DM*Gv{bbYtATQ{l|?d=?sch^r_&B8LUB-(F7T~Ef~vvAW4Er^^Y*y2-8zJ^7r zLv;OM>4PDI>b+Ci6MGj~qt8zpK{c<6JT25Ie0HP{+rQ$A9oIc9vhr?|E(}kh!>s#P zDN7)Z5+R$AtST;KwQ9b)VV>URy9imoC~F$iSq$#b+H?n zUc6h87Th4q4UY)8t&Vw;^4R!YEK#*{&!Ow8k$HXAlV+;ej;v_`5p`RIa}mgx?3b3Y zp-y$0iX0kKO(#PO^Z54!$d?(z9vjT6Px_iIRA@AuTQzIvy&HQ&Hf~#k?YACaj_=1) zK6(zMq|_{cFd>wo^%9Sxev}+PXguvg$f0yut3V5$3GERs3JAon=s6 zO_Z)7NN@r7u)d+d*bX@?Vf1t1=kpy zXC(bJsxG1AxhEpU>oA~i-FWs9IUBd%0t5FQvq_0^rlim-0}KN`3w{Z=nvG`G`1w2K zC*HHibzR+&zqWX-rZOIaq|ENSf426g)*~aVHCt*N;3pA|vOYHCC^S(wVC7q5))AV1 zB3TD{?Be#c)1dfpm-m3$pPnZ(p_ z0)>7If#zwH{YmcR=U_cASxHZETGMFw6xw~Cb8Ge_oymYvCLXK_6D%1|ME(p5VjBGb z+k0BvR+hy_PK(KXA0fQpyu<}$jg}r0R>)>(d!O(7$Zvw{hWe50To^Nd`pU4(2|Z~Q zksJ@cWt`r<-N)^C>IT2?jLBVG%;sc%u$n-M5d=$EOCsv{uwUEMs_CegL_U2E@Mxk= zrFI4MOk`^~iKpg?dK!zCa~4Z~4Ie**>o{w$@O#GD9&gXwod4dj?uUhr3r=76?dlh_ zvJkf5E8`XJ&yp+Oel9lrew6uPv8g`!-j;DK>*UH_ZKTk;V(aIrT;pya;l<~sqi zF1y*7W;&ss+!)2Jx(sC(LZJ{^aqP1oJ{$(Gy~0!_0VB zqL8oSyHtF9clNA@6+lQ*jg?oeA|J;nxGI`^)^>lI11qy6jpc30{t_i%B{aF+fsR0r zJK3Svlg484r+) zEDTR%#HOn<w+4uLOuDYRkvZa(L?JD-)fc?a!rb=fG_Eq>MVZ1Nk!)$Fhu(4 z76B2_h!AnG}`&xPKRXK5!aat0yD+!y3=eUg!1A$>9`(ho{e<|J@8?MQ65&@)Lg8k8kB3F2g$P z_xd6e35SKRllEMgkE1iu0t((nuDVfkKL`WmBgrCBLe@2wkJjN!1=MFd4nz3}>pFLU(sx9w?08b)txCmvKc!{kLtonRRl~>JisO(?_7@kLj3J~9?F)8YIP1rz3jtj;KSN}V`rm>31^2zi z3zho>C~g%dH-%<1hmPo4?PXN{>$E7-Ne*_ZZ!Z0{$7%-=X*WK$1fW-7_UKwo+;(aV zLQmX0gs>8>jf57AL*mWssQpi6IQ%`Lyizh{Mf4>%*U)jvG{;Lzh_`<1y!!_QT; z<65I@ag1a3dwd)$f9gq2> z)!vrR6YeJe-QY^pk2*4Jly1o~+gEHYCiWpzKk$ZYzJ zTRuzoY3owG>hG@liqv!a@Z16XoaU2|S%N(S_xUlMnnZsK)+oF}Dn3F8cQ@1(rZ(J}-=GAi#@l_-7ZXb$DoA&9-!yfUp za}!tQ>e(3@^?(f=iQK0J2Jn{DbwZ(EpC#FJ|2)-Lq1H`&c(~b@7LO9&%wVw@DH11M z_&Y@;Bmf2vKB^v3?P-loLwpx#D~?RajK)K=xkcs`Wp7K_RNH)A_^gV)Zmo@|Q3103 zOvW0a2SMnmo=lM5;>5(I|vr4pXl7!)oUvzrTry-mz|1mC5i=HRt&24~jynD5icIB5=kiC_owQski0C?J-MG2~PUYJ&wNILoc>RQ~c%4*e^!?A!>Z99&L-n5UclAeZ7g52D zhbN);A|-itMhKY--rv(3XJyTn8yy7d*SHc1c27kFQa+#aWcZ(n*fskydc%%Tig-Qv z*%5iOlG_*E4UaOdzpzi6KfB!dw>U5R9=T<1|K`@oGgW&@<=nk^XjS~Y_lmhb-3orR ze+?^s-QD`x4%`?e<95;sgM7HXl0b4qWb@_T{(}eMe$U2{kyXQDgL%|vT$T!Xabn(& zBHxN~jJm(N-(8qArKBNm4SjteYMDn{Yj`kfv#d^oy)GUcW$irFK#Gc>n#taDRaer~ z`AQyDc&Awt_jx9|{^Deh{-%vTP*&8kGm@99=d_%fw;f*6SxJ;4gUO9a6RH*l)pAUPRWt(0)%tTV^}fg-O1>OO$)(IL>rsySMYBD6-M>A(BfO=84(! zfM_UWY@>CKcwLAiB0-tlNjxyMYlU(pmHA*WyN0&w&_KS)<^WAV>!fb&=X!Z%wL!e+Ob8 zO*U?UURm zq?j<6EDq7PEF2jy=tHQ#iyR~yhkLLQH*)i`0vMCXtqp{?4iF5~2~;+UpTCC~QPDO5 z?pESw8_xJk&hNMndiyr`%#`rEwLyO33?h;gl(dOrsJQsso9C&7BEexj@Na&l?A6dU ze?X#8@b*h=ea2_H_{1QTJk&<{t@wgiUjLo+FEu{7AxHRNxLAYn-D>^T8rvkPq2Dy5 zE7ft&liw^z;8gRfoHg+8-}-OgCy91jOKABCST6EE-9QTDzr;s#Gls*G<-&wT*BC07 z1%xVQcm#p=wMM>xm zx@$GSUhg9h($r=Yqqiev$ss=%OJIXP6^MIi+IEE%+Z*3zxSZ`)dn*d?6A30iJftlh z?+e7;=A+SeKm;Ffc0lH%R8u&AiMx`{XX(y1 zvZFzu$?24(5TEtwk4)tfl*o*RgjPHlfSKsa8^QC-8*B0(%6jrC zl}t=Al0}PIjIvgG>O!t`s61w|&K7j6ZBNKBoa)>cX~shnC2c&n4&;tQ z4Y|sfo4@2^1^5AP$nMbud2I5#kKHER^XOtS>a&<_j<+as(#`cn9uH z$z#V$4t51MM;|LFIm#(I9YGeeltfC%5yj)RN$6R+B@aEd0-+dDaeIsPENaUt*>xxA zFr&q}GtcuzwK+NOHpk43Lo)MMo2E5In`BiIp|$<*ICY%t+{X^5@xLzxR^QKE4n^O5 zZ+H@F%JfGb&EMm%GeQ)_9k5S@otc`)Y48rDq#MhY;LX zeze3Qf3yHC@UqOd(Iw*3>_O6`Ms@N8>teqchyh18L2VBR?q4}ZN3)Y!#s^uh<&e=V zA_OGt91B3`pAwk*Ruz2t>1m)myJy|})4Q|{)m7h~Zuy4yDP))WJK2jxS>(?4QEYt* z-bp`C+a(SsV47Seem;)rAc8VS0~efaSD^qZS;S>fj)H)?ftJ zSJwnAtU_bN-pSJye3+WXEVv?)&z*B&$QAxQNA4Lk45p~;=m(e6Gv~}}9pIanl3RUU95ab~aJ?eMI1J8tkTUfuM`?Svjz$f5EQB zYUz4RuhBi^R_{EQULE697o>LfGs|)Y#OdhOLLabho->Ca+ne((C$82DSi!n3ZfFNY zP^hl6iO*G@QN?>I#$;ocfBkXGgDxt|B<{=X<3?$%g7E{a9Z+zBV7&acrJ;^UBO{eM zH1DsHa5vv#+9>b4#h&4H@R1gj()WOZp#PCEvxWN`U8YCe?)uN+&d&rJe-7&lIQJWO zJ$4o?N1lujXzV)9*dBWe?%A7fLI1Atz=XuGUzlqi5kOqM;$c+T7?g?7O<{&W>2u4c=Llv|!S zx?7sPfb>fmLilO=RwcK%)^L#R4DR|c)X_knHdt%lW_Umf0VDsrZcjT zp1x;4$$9>#*d*l3py?$8o73GNdEcj(C7cMX0x>NuA}B;jO9lR?a`y5_QNfj@b;v~I z{IR+T8PDf#a{j9I#VYO%SBq(AawAu;w*6i|FDJo-=ZIR*8=AMg7uWWFcBC^ud*#~l zsLLWEPuqeuXNR}`w6mFrWlrap%E`$gV`9F>Sn)<;9BtKqH&PXjztI)IIv6xO7EcG1 zQL>M6@Kmft3=_+wDv%*pf8A#=Ufx+Ryh?$msmWIXBU%0SGgkTXuC6eVI;bH`(MD|W ztL@QPF(sn_aT2lg@W3aYlZBXNNGJT81MVE{g8aK-&czxQ^h}mCyIq%#1E2w9PWZ0W zFm3u5*-aB;=|PKdLm;?_KvP>#-OYk&`8EkVW#Q}1;5<=^ZS4&ZSAqNQ8=)emfDy(h#Fw9uDDBe zG*=`l1*?lPEme6f;p;jr?hQ9E;N+ql3p zRs7!-jA3(*0$R;H)$L0Pw?GhI!!uUYQhitP-QY@V@RbCn<7k}Hr!p>UyuP*3#}5ZH z_^Y}DJGPdEJc4f13K9j0@yAO8*^~rxS(;5Qb`f;en9VxKE+C9-0du<4&!|dd;a5k* z!#AVSHHTA#ky@Q~=*tZ5S{6&2q+60@9z)fOElecBY~PXOh{OkV!+&>XlN}Dw-@e0t z7s-ChcamhF0$MqHV6=I=1H+AF9QGir7Mu@O*sxD_ zW~0v;B=!wK_i}TNrn^@7zo?x|PtAO;R}cI(q58<{`1LA0V5l`gLxx+Bqm0s(49p9E zJ{4uFka`>+>wYnK49x@?+GbSG)ONrbZJ9OC{0(Y2YAVs`upn;7)P9jIJwdWw{bkS6 zO{u8%u&kD{BL}M~-gvD3N;ks6J(2T%kaG_&Bwz2sW>qvj4K*06Aki{s-daBOrtF}Y zY^YG629B=G5hfpcVNlTYv{9_L{DiYQpbPE06lc^@M`)KcoHqOO>K`sUoU0fn+RSQ{ zB$}}IOvxc61+p|97{CI8ieZEsgTP}YJ@s(%sErAw(tf?ZRKH@zA;VouvV%8hzuDkyHfqw1!`0dCT+GbY+OxGBZ)ON*_;E8f2dKDG26@(MroW-`ruVn#cqE+sjV-h3Op#D7n*HY`DOF!ik?W!N`TgM@Sy|lER}Z7nD)*G(Mxv&wQ~8) zQ)uP}@Ok+>rAV{#?To9OG2iLmL%Ph6$!qu5$bYb z?NQtFq^gycvu#()_9p;3Nj|X`0dvq85xj%ENe?}E)E#r4Iw7L48_XSgrivSRwD zb2gL$rv&b;J~eTk%rb#)t1K^|>Fu*(&~!YoIG7L4@{7X^EF2cEe0Fo@e0j>OH}NKrl?!oQ0ZU-k34*?G_7 zm=72>uS~i*6KO5x6NIiU#&%7agIsI1(3KMgvXPIMy!mx^dVX&enx~>x*<iw88Y(huR;IVo*{O6|)emAO z{qx6L{a0!%0*OuI#;DAj z(B$&NIo~!aMNfMtrS$tCz67)w@M^Q0(mqEOqRdpjF2@bH+HA#+oG0}q>C)tM4Wu;8 zV?iXi@bgeg?LB=lMHxwdn#`{}toU0p)|36-sZ-e>*P|rzdVW}|hIB3ap5KX_Fz&V6 zH_J=~k=+R6Jt0KAhFm{V$hCFpFFjZ9o@k*qO}ohsE{A4e!aqM-1l>h?6M&47PiTNw ztko?p+{kDjkr_cbYDYwq=t=lGc?tEX(+Mt2+kPjjrBQKrqv(BeJ^ zwPrm7ok3Q#Ub+TX-ohTwei4|*2ImCiN>gWFXmw`E*c=6g3)M-W#)|db&O^@a_t^hT z8<%aUd^Inh&Yr|YXFe*lrUwsUReTxOI9eNg(B0ITVAy9hyZ8-hHs!LKLwpP|jjsd9 zX3F^0vA)LLq98iU-4;&ge3x%e{mGf6PGV|<2m-GQ=!e*o>tIDmwqL7 z=>AsGzw>{%2rj@4Vd0}r8tMR;)7MOvK;2gq(&=pj)n2$A*6Sv{+Y}ZIDfqy zMWDo$5btTPGU<*IbBh6)X!`k-k~n^Uz_=3iczITcPZ&Z_Q4rF5`I+#cE4SvB3Ae&G zJW3E{f0Zw?fZQ7_nIQ78E)u?}i&Nwf>a;izsnv3DXVA8H{g_2mBhMJxo#~>5kFi`BELCR^t5HA*zhaODkh=hz2Mji zbC=h84-LAUmVh~tDLzSvY{4scQe3kO0tt!)y`%034SHnq<*LF_1M-0bzofvN;Kka| zq<75T4Gby9p2)V7pZAsuhHE<*u|WKu9-qWGM1SNBRffjtK`-&Jj5<(H%JVafpxbGu zZn494<;jZMt%n;ADrLp$v{0sMaC-}m%6ePchkg-&rwgDh+I=GLmQbYqM)`A6%ZMzA zuDpmS%=BCQ+&ItS>U}3&rkX2EPk&!rXME<~gCgiX1Pr7Oy_|=&1oVYxoO^xR79<1` z^>c21%c!(j8?-E|ir~mxc3%b3s&L$2jr1dWl5UnXtoE88zKTXSpEgDVb&r@mPpuhK z5q>m_q^FddYD%PB4ro^+%`+vR^~h>^tw?|qN6&G8&9%V>I@^yg;$<6voa#z~e+ zs7OsD=g<3IOdU=OEUQh%46Q_&$CmVX*VeBjU%s5lX<=+;i&W$5(O@F z{Nh~|<1+Q(@}#&Yv)`()?+clr%n4fIu)uO7#*IU^iy@fa&G|j_G14#wtf>}79^6u! z8ivNL)oOt=Dzn}X0u6cW4{B)oFTSl|TJJ(V1?3V4b68W-<4>vkk5rf3D61q#K@PI% z1B@Ii%}!i&;AkC|+8P^0!&pFNe$U3HMJ~G_I1X^x5r34{rTkE4ncAPo} zS_05NjFI}?J#p@gJtua!T~EH6)+(aEW$X?|6Cgt!fuEI;Qe_rz^KrX}6bj8#g@s`I4PMy1s5@`QY&}>1p znG(hyR;yRfh9_a=4R4eL%?9$hdEP%>2It6e^o2&s#_yPIgGM?Pb2Hkh-4oWp-oUry z>Kq4emVa!!ZLuU|Apv)t;?{^4f47q6UjBEqshCT9jh z*K%wsvP_ zm{X7h*+x4HSY)#D-OZP42d=gs2Lu^^itFV*@VUc}*QlHwWsL7x>gR^`I6ik*Ofu6W z0qwiN#ue0cj&wFZCj0&P&mNv+U+cK2Tsx_J8u`Ka-|QeLp@I4s6ch?Rx_MA%t#y`) z!S>nk&xgC`Js?XCLd>`*J= z4Lz`Ri^GROA7bl(d@cF()It%wCcGrx3us%x9jHVF)2}psui)LmncIE?Je-Bc<1HZ= zp`pv+?e^|J8`ePm$Wlm4-AJx+ZK-;DB2ov`{d5&DJjpv|zG-i&i5qptBixv$T@6;B zla`k57k<2bMd?aWsI*g3gCImky$_AHymJ&tC#0n>MhNYWE)LjqR)JjQEW$Zxs>x@P zWEO3>c~>F88>w~d-sap1H-OT`Lj9=sh(&m36?pMizpk2fUU?T5Ip%&j=Ci?<@0N|4 zR?|?n%Grmd7a?eCI^Zf{fk{>CK5KMnYDuQw!+6`2zjfQ>(Bzx756|zy_S|5Vb|;TB zODq-u>gMtx^n)u{R;mA}ZljJ<-ydqp$QO9Dp;4)+>SIR-tdMH)kLc(SL+cis=3+oY z_Jn3+cXUC2Z5~Mf*gX7`guwsCegA*`$P91(D#u~|8Lx2YaEh`zq}h@%(C4GHf>gPL HLEwJ?l;i)4 diff --git a/docs/images/server-bootstraping.png b/docs/images/server-bootstraping.png index 2e7bbe5b8a609bdd1d94068d3f4feed82242e75c..446601680477ac21daf051688f215889fe4d8fbf 100644 GIT binary patch literal 26587 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 TP0=y}_$Mu{AXf3w(EtAfPlTTZ diff --git a/docs/images/server-operational.png b/docs/images/server-operational.png index cb7e35b67031968b22dcb1ff06237deb10761c0b..b5dddf888839abb9ed41f41b6c879356f98b1282 100644 GIT binary patch literal 28172 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�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 Date: Fri, 10 Aug 2018 14:39:17 +0200 Subject: [PATCH 05/30] Fixed LabeledResources type name (issue #77) --- api/labels/labels_api.go | 2 +- api/labels/labels_api_mocked.go | 8 ++++---- api/types/labels.go | 2 +- testdata/labels_data.go | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/api/labels/labels_api.go b/api/labels/labels_api.go index 998b65b..380436f 100644 --- a/api/labels/labels_api.go +++ b/api/labels/labels_api.go @@ -74,7 +74,7 @@ func (lbl *LabelService) CreateLabel(labelVector *map[string]interface{}) (label } // AddLabel assigns a single label from a single labelable resource -func (lbl *LabelService) AddLabel(labelVector *map[string]interface{}, labelID string) (labeledResources []types.LabeledResources, err error) { +func (lbl *LabelService) AddLabel(labelVector *map[string]interface{}, labelID string) (labeledResources []types.LabeledResource, err error) { log.Debug("AddLabel") data, status, err := lbl.concertoService.Post(fmt.Sprintf("/v1/labels/%s/resources", labelID), labelVector) diff --git a/api/labels/labels_api_mocked.go b/api/labels/labels_api_mocked.go index ec0d5e0..bbde988 100644 --- a/api/labels/labels_api_mocked.go +++ b/api/labels/labels_api_mocked.go @@ -253,7 +253,7 @@ func CreateLabelFailJSONMocked(t *testing.T, labelIn *types.Label) *types.Label } // AddLabelMocked test mocked function -func AddLabelMocked(t *testing.T, labelIn *types.Label, labeledResourcesOut []types.LabeledResources) []types.LabeledResources { +func AddLabelMocked(t *testing.T, labelIn *types.Label, labeledResourcesOut []types.LabeledResource) []types.LabeledResource { assert := assert.New(t) @@ -281,7 +281,7 @@ func AddLabelMocked(t *testing.T, labelIn *types.Label, labeledResourcesOut []ty } // AddLabelFailErrMocked test mocked function -func AddLabelFailErrMocked(t *testing.T, labelIn *types.Label, labeledResourcesOut []types.LabeledResources) []types.LabeledResources { +func AddLabelFailErrMocked(t *testing.T, labelIn *types.Label, labeledResourcesOut []types.LabeledResource) []types.LabeledResource { assert := assert.New(t) @@ -310,7 +310,7 @@ func AddLabelFailErrMocked(t *testing.T, labelIn *types.Label, labeledResourcesO } // AddLabelFailStatusMocked test mocked function -func AddLabelFailStatusMocked(t *testing.T, labelIn *types.Label, labeledResourcesOut []types.LabeledResources) []types.LabeledResources { +func AddLabelFailStatusMocked(t *testing.T, labelIn *types.Label, labeledResourcesOut []types.LabeledResource) []types.LabeledResource { assert := assert.New(t) @@ -339,7 +339,7 @@ func AddLabelFailStatusMocked(t *testing.T, labelIn *types.Label, labeledResourc } // AddLabelFailJSONMocked test mocked function -func AddLabelFailJSONMocked(t *testing.T, labelIn *types.Label, labeledResourcesOut []types.LabeledResources) []types.LabeledResources { +func AddLabelFailJSONMocked(t *testing.T, labelIn *types.Label, labeledResourcesOut []types.LabeledResource) []types.LabeledResource { assert := assert.New(t) diff --git a/api/types/labels.go b/api/types/labels.go index b079714..5397a81 100644 --- a/api/types/labels.go +++ b/api/types/labels.go @@ -8,7 +8,7 @@ type Label struct { Value string `json:"value" header:"VALUE" show:"nolist"` } -type LabeledResources struct { +type LabeledResource struct { ID string `json:"id" header:"ID"` ResourceType string `json:"resource_type" header:"RESOURCE_TYPE"` } diff --git a/testdata/labels_data.go b/testdata/labels_data.go index 0b5af93..9597576 100644 --- a/testdata/labels_data.go +++ b/testdata/labels_data.go @@ -47,9 +47,9 @@ func GetLabelWithNamespaceData() *[]types.Label { } // GetLabeledResourcesData loads test data -func GetLabeledResourcesData() *[]types.LabeledResources { +func GetLabeledResourcesData() *[]types.LabeledResource { - testLabeledResources := []types.LabeledResources{ + testLabeledResources := []types.LabeledResource{ { ID: "fakeID0", ResourceType: "server", From 709ede9cf99266966046fb0cd19363371d9f4073 Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Fri, 10 Aug 2018 14:45:40 +0200 Subject: [PATCH 06/30] Fixed usage action names for labelable resources (issue #77) --- blueprint/scripts/subcommands.go | 4 ++-- blueprint/templates/subcommands.go | 4 ++-- cloud/servers/subcommands.go | 4 ++-- cloud/ssh_profiles/subcommands.go | 4 ++-- network/firewall_profiles/subcommands.go | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/blueprint/scripts/subcommands.go b/blueprint/scripts/subcommands.go index d5f5aec..93984d6 100644 --- a/blueprint/scripts/subcommands.go +++ b/blueprint/scripts/subcommands.go @@ -96,7 +96,7 @@ func SubCommands() []cli.Command { }, { Name: "add-label", - Usage: "This action assign a single label from a single labelable resource", + Usage: "This action assigns a single label from a single labelable resource", Action: cmd.LabelAdd, Flags: []cli.Flag{ cli.StringFlag{ @@ -117,7 +117,7 @@ func SubCommands() []cli.Command { }, { Name: "remove-label", - Usage: "This action de-assign a single label from a single labelable resource", + Usage: "This action unassigns a single label from a single labelable resource", Action: cmd.LabelRemove, Flags: []cli.Flag{ cli.StringFlag{ diff --git a/blueprint/templates/subcommands.go b/blueprint/templates/subcommands.go index 3c03c6c..557cd3a 100644 --- a/blueprint/templates/subcommands.go +++ b/blueprint/templates/subcommands.go @@ -213,7 +213,7 @@ func SubCommands() []cli.Command { }, { Name: "add-label", - Usage: "This action assign a single label from a single labelable resource", + Usage: "This action assigns a single label from a single labelable resource", Action: cmd.LabelAdd, Flags: []cli.Flag{ cli.StringFlag{ @@ -234,7 +234,7 @@ func SubCommands() []cli.Command { }, { Name: "remove-label", - Usage: "This action de-assign a single label from a single labelable resource", + Usage: "This action unassigns a single label from a single labelable resource", Action: cmd.LabelRemove, Flags: []cli.Flag{ cli.StringFlag{ diff --git a/cloud/servers/subcommands.go b/cloud/servers/subcommands.go index e419ad3..4c9ac7d 100644 --- a/cloud/servers/subcommands.go +++ b/cloud/servers/subcommands.go @@ -174,7 +174,7 @@ func SubCommands() []cli.Command { }, { Name: "add-label", - Usage: "This action assign a single label from a single labelable resource", + Usage: "This action assigns a single label from a single labelable resource", Action: cmd.LabelAdd, Flags: []cli.Flag{ cli.StringFlag{ @@ -195,7 +195,7 @@ func SubCommands() []cli.Command { }, { Name: "remove-label", - Usage: "This action de-assign a single label from a single labelable resource", + Usage: "This action unassigns a single label from a single labelable resource", Action: cmd.LabelRemove, Flags: []cli.Flag{ cli.StringFlag{ diff --git a/cloud/ssh_profiles/subcommands.go b/cloud/ssh_profiles/subcommands.go index 9b8eddb..2122135 100644 --- a/cloud/ssh_profiles/subcommands.go +++ b/cloud/ssh_profiles/subcommands.go @@ -88,7 +88,7 @@ func SubCommands() []cli.Command { }, { Name: "add-label", - Usage: "This action assign a single label from a single labelable resource", + Usage: "This action assigns a single label from a single labelable resource", Action: cmd.LabelAdd, Flags: []cli.Flag{ cli.StringFlag{ @@ -109,7 +109,7 @@ func SubCommands() []cli.Command { }, { Name: "remove-label", - Usage: "This action de-assign a single label from a single labelable resource", + Usage: "This action unassigns a single label from a single labelable resource", Action: cmd.LabelRemove, Flags: []cli.Flag{ cli.StringFlag{ diff --git a/network/firewall_profiles/subcommands.go b/network/firewall_profiles/subcommands.go index e2519a7..4e03f34 100644 --- a/network/firewall_profiles/subcommands.go +++ b/network/firewall_profiles/subcommands.go @@ -88,7 +88,7 @@ func SubCommands() []cli.Command { }, { Name: "add-label", - Usage: "This action assign a single label from a single labelable resource", + Usage: "This action assigns a single label from a single labelable resource", Action: cmd.LabelAdd, Flags: []cli.Flag{ cli.StringFlag{ @@ -109,7 +109,7 @@ func SubCommands() []cli.Command { }, { Name: "remove-label", - Usage: "This action de-assign a single label from a single labelable resource", + Usage: "This action unassigns a single label from a single labelable resource", Action: cmd.LabelRemove, Flags: []cli.Flag{ cli.StringFlag{ From ac7c4cb8fef258fb589a6f1f1f0fbfee63131eea Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Tue, 26 Feb 2019 10:42:14 +0100 Subject: [PATCH 07/30] Implemented labelable interface (issue #77) Closes #77 --- api/types/firewall_profiles.go | 15 ++- api/types/labels.go | 47 ++++++++++ api/types/scripts.go | 3 +- api/types/servers.go | 25 +++-- api/types/ssh_profiles.go | 13 ++- api/types/templates.go | 3 +- cmd/firewall_profiles_cmd.go | 31 ++++--- cmd/labels_cmd.go | 90 +++++++----------- cmd/scripts_cmd.go | 31 ++++--- cmd/servers_cmd.go | 39 ++++---- cmd/ssh_profiles_cmd.go | 31 ++++--- cmd/template_cmd.go | 31 ++++--- utils/format/formatter.go | 5 +- utils/format/textformatter.go | 163 +++++++++++++++------------------ 14 files changed, 279 insertions(+), 248 deletions(-) diff --git a/api/types/firewall_profiles.go b/api/types/firewall_profiles.go index 93230b7..8ef416e 100644 --- a/api/types/firewall_profiles.go +++ b/api/types/firewall_profiles.go @@ -1,14 +1,13 @@ package types type FirewallProfile struct { - ID string `json:"id" header:"ID"` - Name string `json:"name,omitempty" header:"NAME"` - Description string `json:"description,omitempty" header:"DESCRIPTION"` - Default bool `json:"default,omitempty" header:"DEFAULT"` - Rules []Rule `json:"rules,omitempty" header:"RULES" show:"nolist"` - ResourceType string `json:"resource_type" header:"RESOURCE_TYPE" show:"nolist"` - LabelIDs []string `json:"label_ids" header:"LABEL_IDS" show:"nolist,noshow"` - Labels []string `json:"labels" header:"LABELS"` + ID string `json:"id" header:"ID"` + Name string `json:"name,omitempty" header:"NAME"` + Description string `json:"description,omitempty" header:"DESCRIPTION"` + Default bool `json:"default,omitempty" header:"DEFAULT"` + Rules []Rule `json:"rules,omitempty" header:"RULES" show:"nolist"` + ResourceType string `json:"resource_type" header:"RESOURCE_TYPE" show:"nolist"` + LabelableFields } type Rule struct { diff --git a/api/types/labels.go b/api/types/labels.go index 5397a81..a90a952 100644 --- a/api/types/labels.go +++ b/api/types/labels.go @@ -12,3 +12,50 @@ type LabeledResource struct { ID string `json:"id" header:"ID"` ResourceType string `json:"resource_type" header:"RESOURCE_TYPE"` } + +type LabelableFields struct { + LabelIDs []string `json:"label_ids" header:"LABEL_IDS" show:"nolist,noshow"` + Labels []string `json:"labels" header:"LABELS"` +} + +type Labelable interface { + FilterByLabelIDs(labelIDs []string) bool + AssignLabelIDs(labelIDs []string) + FillInLabelNames(labelNamesByID map[string]string) +} + +func (lf *LabelableFields) FilterByLabelIDs(labelIDs []string) bool { + for _, lid := range labelIDs { + var labelIDFound bool + for _, resourceLabelID := range lf.LabelIDs { + if lid == resourceLabelID { + labelIDFound = true + break + } + } + if !labelIDFound { + return false + } + } + return true +} + +func (lf *LabelableFields) AssignLabelIDs(labelIDs []string) { + for _, lid := range labelIDs { + for _, resourceLabelID := range lf.LabelIDs { + if lid == resourceLabelID { + break + } + } + } +} + +func (lf *LabelableFields) FillInLabelNames(labelNamesByID map[string]string) { + for lID, lName := range labelNamesByID { + for _, resourceLabelID := range lf.LabelIDs { + if lID == resourceLabelID { + lf.Labels = append(lf.Labels, lName) + } + } + } +} diff --git a/api/types/scripts.go b/api/types/scripts.go index 05fbd9d..36d127a 100644 --- a/api/types/scripts.go +++ b/api/types/scripts.go @@ -8,6 +8,5 @@ type Script struct { Code string `json:"code" header:"CODE" show:"nolist"` Parameters []string `json:"parameters" header:"PARAMETERS"` ResourceType string `json:"resource_type" header:"RESOURCE_TYPE" show:"nolist"` - LabelIDs []string `json:"label_ids" header:"LABEL_IDS" show:"nolist,noshow"` - Labels []string `json:"labels" header:"LABELS"` + LabelableFields } diff --git a/api/types/servers.go b/api/types/servers.go index c008875..dcab057 100644 --- a/api/types/servers.go +++ b/api/types/servers.go @@ -1,19 +1,18 @@ package types type Server struct { - ID string `json:"id" header:"ID"` - Name string `json:"name" header:"NAME"` - Fqdn string `json:"fqdn" header:"FQDN"` - State string `json:"state" header:"STATE"` - PublicIP string `json:"public_ip" header:"PUBLIC_IP"` - TemplateID string `json:"template_id" header:"TEMPLATE_ID"` - ServerPlanID string `json:"server_plan_id" header:"SERVER_PLAN_ID"` - CloudAccountID string `json:"cloud_account_id" header:"CLOUD_ACCOUNT_ID"` - SSHProfileID string `json:"ssh_profile_id" header:"SSH_PROFILE_ID"` - FirewallProfileID string `json:"firewall_profile_id" header:"FIREWALL_PROFILE_ID"` - ResourceType string `json:"resource_type" header:"RESOURCE_TYPE" show:"nolist"` - LabelIDs []string `json:"label_ids" header:"LABEL_IDS" show:"nolist,noshow"` - Labels []string `json:"labels" header:"LABELS"` + ID string `json:"id" header:"ID"` + Name string `json:"name" header:"NAME"` + Fqdn string `json:"fqdn" header:"FQDN"` + State string `json:"state" header:"STATE"` + PublicIP string `json:"public_ip" header:"PUBLIC_IP"` + TemplateID string `json:"template_id" header:"TEMPLATE_ID"` + ServerPlanID string `json:"server_plan_id" header:"SERVER_PLAN_ID"` + CloudAccountID string `json:"cloud_account_id" header:"CLOUD_ACCOUNT_ID"` + SSHProfileID string `json:"ssh_profile_id" header:"SSH_PROFILE_ID"` + FirewallProfileID string `json:"firewall_profile_id" header:"FIREWALL_PROFILE_ID"` + ResourceType string `json:"resource_type" header:"RESOURCE_TYPE" show:"nolist"` + LabelableFields } type Dns struct { diff --git a/api/types/ssh_profiles.go b/api/types/ssh_profiles.go index 04a2091..ccd8162 100644 --- a/api/types/ssh_profiles.go +++ b/api/types/ssh_profiles.go @@ -1,11 +1,10 @@ package types type SSHProfile struct { - ID string `json:"id" header:"ID"` - Name string `json:"name" header:"NAME"` - PublicKey string `json:"public_key" header:"PUBLIC_KEY"` - PrivateKey string `json:"private_key" header:"PRIVATE_KEY" show:"nolist"` - ResourceType string `json:"resource_type" header:"RESOURCE_TYPE" show:"nolist"` - LabelIDs []string `json:"label_ids" header:"LABEL_IDS" show:"nolist,noshow"` - Labels []string `json:"labels" header:"LABELS"` + ID string `json:"id" header:"ID"` + Name string `json:"name" header:"NAME"` + PublicKey string `json:"public_key" header:"PUBLIC_KEY"` + PrivateKey string `json:"private_key" header:"PRIVATE_KEY" show:"nolist"` + ResourceType string `json:"resource_type" header:"RESOURCE_TYPE" show:"nolist"` + LabelableFields } diff --git a/api/types/templates.go b/api/types/templates.go index 85ff931..dc484cf 100644 --- a/api/types/templates.go +++ b/api/types/templates.go @@ -12,8 +12,7 @@ type Template struct { ServiceList []string `json:"service_list,omitempty" header:"SERVICE LIST" show:"nolist"` ConfigurationAttributes *json.RawMessage `json:"configuration_attributes,omitempty" header:"CONFIGURATION ATTRIBUTES" show:"nolist"` ResourceType string `json:"resource_type" header:"RESOURCE_TYPE" show:"nolist"` - LabelIDs []string `json:"label_ids" header:"LABEL_IDS" show:"nolist,noshow"` - Labels []string `json:"labels" header:"LABELS"` + LabelableFields } // TemplateScript stores a templates' script info diff --git a/cmd/firewall_profiles_cmd.go b/cmd/firewall_profiles_cmd.go index 569bbea..4842732 100644 --- a/cmd/firewall_profiles_cmd.go +++ b/cmd/firewall_profiles_cmd.go @@ -39,18 +39,25 @@ func FirewallProfileList(c *cli.Context) error { formatter.PrintFatal("Couldn't receive firewallProfile data", err) } - filteredResources, err := LabelFiltering(c, firewallProfiles) - if err != nil { - formatter.PrintFatal("Couldn't list firewall profiles filtered by labels", err) - } - if filteredResources != nil { - firewallProfiles = nil - for _, v := range *filteredResources { - firewallProfiles = append(firewallProfiles, v.(types.FirewallProfile)) + labelables := make([]*types.LabelableFields, 0, len(firewallProfiles)) + for i := range firewallProfiles { + labelables = append(labelables, &firewallProfiles[i].LabelableFields) + } + + filteredLabelables := LabelFiltering(c, labelables) + + tmp := firewallProfiles + firewallProfiles = nil + if len(filteredLabelables) > 0 { + for _, labelable := range filteredLabelables { + for i := range tmp { + if &tmp[i].LabelableFields == labelable { + firewallProfiles = append(firewallProfiles, tmp[i]) + } + } } } - LabelAssignNamesForIDs(c, firewallProfiles) if err = formatter.PrintList(firewallProfiles); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -68,7 +75,7 @@ func FirewallProfileShow(c *cli.Context) error { formatter.PrintFatal("Couldn't receive firewallProfile data", err) } - LabelAssignNamesForIDs(c, firewallProfile) + LabelAssignNamesForIDs(c, []*types.LabelableFields{&firewallProfile.LabelableFields}) if err = formatter.PrintItem(*firewallProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -103,7 +110,7 @@ func FirewallProfileCreate(c *cli.Context) error { formatter.PrintFatal("Couldn't create firewallProfile", err) } - LabelAssignNamesForIDs(c, firewallProfile) + LabelAssignNamesForIDs(c, []*types.LabelableFields{&firewallProfile.LabelableFields}) if err = formatter.PrintItem(*firewallProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -125,7 +132,7 @@ func FirewallProfileUpdate(c *cli.Context) error { formatter.PrintFatal("Couldn't update firewallProfile", err) } - LabelAssignNamesForIDs(c, firewallProfile) + LabelAssignNamesForIDs(c, []*types.LabelableFields{&firewallProfile.LabelableFields}) if err = formatter.PrintItem(*firewallProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } diff --git a/cmd/labels_cmd.go b/cmd/labels_cmd.go index 5915e14..245618e 100644 --- a/cmd/labels_cmd.go +++ b/cmd/labels_cmd.go @@ -2,12 +2,11 @@ package cmd import ( "fmt" - "reflect" "regexp" "strings" - "github.com/codegangsta/cli" "github.com/ingrammicro/concerto/api/labels" + "github.com/ingrammicro/concerto/api/types" "github.com/ingrammicro/concerto/utils" "github.com/ingrammicro/concerto/utils/format" ) @@ -65,76 +64,51 @@ func LabelCreate(c *cli.Context) error { return nil } -// LabelFiltering subcommand function receives an interface representing a collection of labelable resources (Server, Template, ...) +// LabelFiltering subcommand function receives a collection of references to labelable objects // Evaluates the matching of assigned labels with the labels requested for filtering. -func LabelFiltering(c *cli.Context, items interface{}) (*[]interface{}, error) { +func LabelFiltering(c *cli.Context, inItems []*types.LabelableFields) []*types.LabelableFields { debugCmdFuncInfo(c) - if c.String("labels") != "" { - its := reflect.ValueOf(items) - if its.Type().Kind() != reflect.Slice { - return nil, fmt.Errorf("Cannot process label filtering. Slice expected") - } + labelsMapNameToID, labelsMapIDToName := LabelLoadsMapping(c) - // evaluates labels + var outItems []*types.LabelableFields + if c.String("labels") != "" { _, formatter := WireUpLabel(c) labelNamesIn := LabelsUnifyInputNames(c.String("labels"), formatter) - // Load Labels mapping ID <-> NAME - _, labelsMapIDToName := LabelLoadsMapping(c) - - var filteredResources []interface{} - var tmpLabelNames []string - // per resource (Server, Template, ...) - for i := 0; i < its.Len(); i++ { - tmpLabelNames = nil - labelIDs := reflect.ValueOf(its.Index(i).FieldByName("LabelIDs").Interface()) - if len := labelIDs.Len(); len > 0 { - for j := 0; j < len; j++ { - tmpLabelNames = append(tmpLabelNames, labelsMapIDToName[labelIDs.Index(j).String()]) - } - } - // checks whether received labels match for resources labels - if utils.Subset(labelNamesIn, tmpLabelNames) { - filteredResources = append(filteredResources, its.Index(i).Interface()) + var labelIDsIn []string + for _, name := range labelNamesIn { + labelIDsIn = append(labelIDsIn, labelsMapNameToID[name]) + } + + for i := 0; i < len(inItems); i++ { + if inItems[i].FilterByLabelIDs(labelIDsIn) { + // added filtered + outItems = append(outItems, inItems[i]) } } - return &filteredResources, nil + } else { + // all included + outItems = inItems + } + + // Assigns the Labels names + for i := 0; i < len(outItems); i++ { + outItems[i].FillInLabelNames(labelsMapIDToName) } - return nil, nil + + return outItems } -// LabelAssignNamesForIDs subcommand function receives an interface representing labelable resources (Server, Template, ...) +// LabelAssignNamesForIDs subcommand function receives a collection of references to labelables objects // Resolves the Labels names associated to a each resource from given Labels ids, loading object with respective labels names -func LabelAssignNamesForIDs(c *cli.Context, items interface{}) { +func LabelAssignNamesForIDs(c *cli.Context, items []*types.LabelableFields) { debugCmdFuncInfo(c) - var tmpLabelNames []string - // Load Labels mapping ID <-> NAME _, labelsMapIDToName := LabelLoadsMapping(c) - - its := reflect.ValueOf(items) - if its.Type().Kind() == reflect.Slice { // resources collection - // per resource (Server, Template, ...) - for i := 0; i < its.Len(); i++ { - tmpLabelNames = nil - labelIDs := reflect.ValueOf(its.Index(i).FieldByName("LabelIDs").Interface()) - if len := labelIDs.Len(); len > 0 { - for j := 0; j < len; j++ { - tmpLabelNames = append(tmpLabelNames, labelsMapIDToName[labelIDs.Index(j).String()]) - } - } - its.Index(i).FieldByName("Labels").Set(reflect.ValueOf(tmpLabelNames)) - } - } else if its.Type().Kind() == reflect.Ptr { // resource - labelIDs := reflect.Indirect(its).FieldByName("LabelIDs") - if len := labelIDs.Len(); len > 0 { - for j := 0; j < len; j++ { - tmpLabelNames = append(tmpLabelNames, labelsMapIDToName[labelIDs.Index(j).String()]) - } - } - reflect.Indirect(its).FieldByName("Labels").Set(reflect.ValueOf(tmpLabelNames)) + for i := 0; i < len(items); i++ { + items[i].FillInLabelNames(labelsMapIDToName) } } @@ -171,9 +145,9 @@ func LabelsUnifyInputNames(labelsNames string, formatter format.Formatter) []str return labelNamesIn } -// LabelResolution subcommand function retrieves a labels map(Name<->ID) based on label names received to be procesed. +// LabelResolution subcommand function retrieves a labels map(Name<->ID) based on label names received to be processed. // The function evaluates the received labels names (comma separated string); with them, solves the assigned IDs for the given labels names. -// If the label name is not avaiable in IMCO yet, it is created. +// If the label name is not available in IMCO yet, it is created. func LabelResolution(c *cli.Context, labelsNames string) []string { debugCmdFuncInfo(c) @@ -181,7 +155,7 @@ func LabelResolution(c *cli.Context, labelsNames string) []string { labelNamesIn := LabelsUnifyInputNames(labelsNames, formatter) labelsMapNameToID, _ := LabelLoadsMapping(c) - // Obtain output mapped labels Name<->ID; currenlty in IMCO platform as well as if creation is required + // Obtain output mapped labels Name<->ID; currently in IMCO platform as well as if creation is required labelsOutMap := make(map[string]string) for _, name := range labelNamesIn { // check if the label already exists in IMCO, creates it if it does not exist diff --git a/cmd/scripts_cmd.go b/cmd/scripts_cmd.go index 83f610a..a194982 100644 --- a/cmd/scripts_cmd.go +++ b/cmd/scripts_cmd.go @@ -41,18 +41,25 @@ func ScriptsList(c *cli.Context) error { formatter.PrintFatal("Couldn't receive script data", err) } - filteredResources, err := LabelFiltering(c, scripts) - if err != nil { - formatter.PrintFatal("Couldn't list scripts filtered by labels", err) - } - if filteredResources != nil { - scripts = nil - for _, v := range *filteredResources { - scripts = append(scripts, v.(types.Script)) + labelables := make([]*types.LabelableFields, 0, len(scripts)) + for i := range scripts { + labelables = append(labelables, &scripts[i].LabelableFields) + } + + filteredLabelables := LabelFiltering(c, labelables) + + tmp := scripts + scripts = nil + if len(filteredLabelables) > 0 { + for _, labelable := range filteredLabelables { + for i := range tmp { + if &tmp[i].LabelableFields == labelable { + scripts = append(scripts, tmp[i]) + } + } } } - LabelAssignNamesForIDs(c, scripts) if err = formatter.PrintList(scripts); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -70,7 +77,7 @@ func ScriptShow(c *cli.Context) error { formatter.PrintFatal("Couldn't receive script data", err) } - LabelAssignNamesForIDs(c, script) + LabelAssignNamesForIDs(c, []*types.LabelableFields{&script.LabelableFields}) if err = formatter.PrintItem(*script); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -101,7 +108,7 @@ func ScriptCreate(c *cli.Context) error { formatter.PrintFatal("Couldn't create script", err) } - LabelAssignNamesForIDs(c, script) + LabelAssignNamesForIDs(c, []*types.LabelableFields{&script.LabelableFields}) if err = formatter.PrintItem(*script); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -119,7 +126,7 @@ func ScriptUpdate(c *cli.Context) error { formatter.PrintFatal("Couldn't update script", err) } - LabelAssignNamesForIDs(c, script) + LabelAssignNamesForIDs(c, []*types.LabelableFields{&script.LabelableFields}) if err = formatter.PrintItem(*script); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } diff --git a/cmd/servers_cmd.go b/cmd/servers_cmd.go index 8bd30d2..467060b 100644 --- a/cmd/servers_cmd.go +++ b/cmd/servers_cmd.go @@ -39,18 +39,25 @@ func ServerList(c *cli.Context) error { formatter.PrintFatal("Couldn't receive server data", err) } - filteredResources, err := LabelFiltering(c, servers) - if err != nil { - formatter.PrintFatal("Couldn't list servers filtered by labels", err) - } - if filteredResources != nil { - servers = nil - for _, v := range *filteredResources { - servers = append(servers, v.(types.Server)) + labelables := make([]*types.LabelableFields, 0, len(servers)) + for i := range servers { + labelables = append(labelables, &servers[i].LabelableFields) + } + + filteredLabelables := LabelFiltering(c, labelables) + + tmp := servers + servers = nil + if len(filteredLabelables) > 0 { + for _, labelable := range filteredLabelables { + for i := range tmp { + if &tmp[i].LabelableFields == labelable { + servers = append(servers, tmp[i]) + } + } } } - LabelAssignNamesForIDs(c, servers) if err = formatter.PrintList(servers); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -68,7 +75,7 @@ func ServerShow(c *cli.Context) error { formatter.PrintFatal("Couldn't receive server data", err) } - LabelAssignNamesForIDs(c, server) + LabelAssignNamesForIDs(c, []*types.LabelableFields{&server.LabelableFields}) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -100,7 +107,7 @@ func ServerCreate(c *cli.Context) error { formatter.PrintFatal("Couldn't create server", err) } - LabelAssignNamesForIDs(c, server) + LabelAssignNamesForIDs(c, []*types.LabelableFields{&server.LabelableFields}) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -118,7 +125,7 @@ func ServerUpdate(c *cli.Context) error { formatter.PrintFatal("Couldn't update server", err) } - LabelAssignNamesForIDs(c, server) + LabelAssignNamesForIDs(c, []*types.LabelableFields{&server.LabelableFields}) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -136,7 +143,7 @@ func ServerBoot(c *cli.Context) error { formatter.PrintFatal("Couldn't boot server", err) } - LabelAssignNamesForIDs(c, server) + LabelAssignNamesForIDs(c, []*types.LabelableFields{&server.LabelableFields}) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -154,7 +161,7 @@ func ServerReboot(c *cli.Context) error { formatter.PrintFatal("Couldn't reboot server", err) } - LabelAssignNamesForIDs(c, server) + LabelAssignNamesForIDs(c, []*types.LabelableFields{&server.LabelableFields}) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -172,7 +179,7 @@ func ServerShutdown(c *cli.Context) error { formatter.PrintFatal("Couldn't shutdown server", err) } - LabelAssignNamesForIDs(c, server) + LabelAssignNamesForIDs(c, []*types.LabelableFields{&server.LabelableFields}) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -190,7 +197,7 @@ func ServerOverride(c *cli.Context) error { formatter.PrintFatal("Couldn't override server", err) } - LabelAssignNamesForIDs(c, server) + LabelAssignNamesForIDs(c, []*types.LabelableFields{&server.LabelableFields}) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } diff --git a/cmd/ssh_profiles_cmd.go b/cmd/ssh_profiles_cmd.go index 8d01bba..7853ca9 100644 --- a/cmd/ssh_profiles_cmd.go +++ b/cmd/ssh_profiles_cmd.go @@ -39,18 +39,25 @@ func SSHProfileList(c *cli.Context) error { formatter.PrintFatal("Couldn't receive sshProfile data", err) } - filteredResources, err := LabelFiltering(c, sshProfiles) - if err != nil { - formatter.PrintFatal("Couldn't list SSH profiles filtered by labels", err) - } - if filteredResources != nil { - sshProfiles = nil - for _, v := range *filteredResources { - sshProfiles = append(sshProfiles, v.(types.SSHProfile)) + labelables := make([]*types.LabelableFields, 0, len(sshProfiles)) + for i := range sshProfiles { + labelables = append(labelables, &sshProfiles[i].LabelableFields) + } + + filteredLabelables := LabelFiltering(c, labelables) + + tmp := sshProfiles + sshProfiles = nil + if len(filteredLabelables) > 0 { + for _, labelable := range filteredLabelables { + for i := range tmp { + if &tmp[i].LabelableFields == labelable { + sshProfiles = append(sshProfiles, tmp[i]) + } + } } } - LabelAssignNamesForIDs(c, sshProfiles) if err = formatter.PrintList(sshProfiles); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -68,7 +75,7 @@ func SSHProfileShow(c *cli.Context) error { formatter.PrintFatal("Couldn't receive sshProfile data", err) } - LabelAssignNamesForIDs(c, sshProfile) + LabelAssignNamesForIDs(c, []*types.LabelableFields{&sshProfile.LabelableFields}) if err = formatter.PrintItem(*sshProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -98,7 +105,7 @@ func SSHProfileCreate(c *cli.Context) error { formatter.PrintFatal("Couldn't create sshProfile", err) } - LabelAssignNamesForIDs(c, sshProfile) + LabelAssignNamesForIDs(c, []*types.LabelableFields{&sshProfile.LabelableFields}) if err = formatter.PrintItem(*sshProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -116,7 +123,7 @@ func SSHProfileUpdate(c *cli.Context) error { formatter.PrintFatal("Couldn't update sshProfile", err) } - LabelAssignNamesForIDs(c, sshProfile) + LabelAssignNamesForIDs(c, []*types.LabelableFields{&sshProfile.LabelableFields}) if err = formatter.PrintItem(*sshProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } diff --git a/cmd/template_cmd.go b/cmd/template_cmd.go index ab181e5..4e97873 100644 --- a/cmd/template_cmd.go +++ b/cmd/template_cmd.go @@ -39,18 +39,25 @@ func TemplateList(c *cli.Context) error { formatter.PrintFatal("Couldn't receive template data", err) } - filteredResources, err := LabelFiltering(c, templates) - if err != nil { - formatter.PrintFatal("Couldn't list templates filtered by labels", err) - } - if filteredResources != nil { - templates = nil - for _, v := range *filteredResources { - templates = append(templates, v.(types.Template)) + labelables := make([]*types.LabelableFields, 0, len(templates)) + for i := range templates { + labelables = append(labelables, &templates[i].LabelableFields) + } + + filteredLabelables := LabelFiltering(c, labelables) + + tmp := templates + templates = nil + if len(filteredLabelables) > 0 { + for _, labelable := range filteredLabelables { + for i := range tmp { + if &tmp[i].LabelableFields == labelable { + templates = append(templates, tmp[i]) + } + } } } - LabelAssignNamesForIDs(c, templates) if err = formatter.PrintList(templates); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -68,7 +75,7 @@ func TemplateShow(c *cli.Context) error { formatter.PrintFatal("Couldn't receive template data", err) } - LabelAssignNamesForIDs(c, template) + LabelAssignNamesForIDs(c, []*types.LabelableFields{&template.LabelableFields}) if err = formatter.PrintItem(*template); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -104,7 +111,7 @@ func TemplateCreate(c *cli.Context) error { formatter.PrintFatal("Couldn't create template", err) } - LabelAssignNamesForIDs(c, template) + LabelAssignNamesForIDs(c, []*types.LabelableFields{&template.LabelableFields}) if err = formatter.PrintItem(*template); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -129,7 +136,7 @@ func TemplateUpdate(c *cli.Context) error { formatter.PrintFatal("Couldn't update template", err) } - LabelAssignNamesForIDs(c, template) + LabelAssignNamesForIDs(c, []*types.LabelableFields{&template.LabelableFields}) if err = formatter.PrintItem(*template); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } diff --git a/utils/format/formatter.go b/utils/format/formatter.go index 0ebbb6f..869f4e8 100644 --- a/utils/format/formatter.go +++ b/utils/format/formatter.go @@ -16,7 +16,6 @@ var osExit = os.Exit type Formatter interface { PrintItem(item interface{}) error PrintList(items interface{}) error - //PrintList(items [][]string, headers []string) error PrintError(context string, err error) PrintFatal(context string, err error) } @@ -24,8 +23,8 @@ type Formatter interface { var formatter Formatter // InitializeFormatter creates a singleton Formatter -func InitializeFormatter(ftype string, out io.Writer) { - if ftype == "json" { +func InitializeFormatter(formatterType string, out io.Writer) { + if formatterType == "json" { formatter = NewJSONFormatter(out) } else { formatter = NewTextFormatter(out) diff --git a/utils/format/textformatter.go b/utils/format/textformatter.go index 67f5680..3acbe43 100644 --- a/utils/format/textformatter.go +++ b/utils/format/textformatter.go @@ -2,6 +2,7 @@ package format import ( "fmt" + "github.com/ingrammicro/concerto/utils" "io" "reflect" "strings" @@ -10,8 +11,6 @@ import ( log "github.com/Sirupsen/logrus" ) -const minifySeconds string = "minifySeconds" - // TextFormatter prints items and lists type TextFormatter struct { output io.Writer @@ -26,42 +25,85 @@ func NewTextFormatter(out io.Writer) *TextFormatter { } } -// PrintItem prints an item -func (f *TextFormatter) PrintItem(item interface{}) error { - log.Debug("PrintItem") +func (f *TextFormatter) printItemAux(w *tabwriter.Writer, item interface{}) error { + log.Debug("printItemAux") it := reflect.ValueOf(item) - nf := it.NumField() - - w := tabwriter.NewWriter(f.output, 15, 1, 3, ' ', 0) - for i := 0; i < nf; i++ { - // hide fields - bShow := true + for i := 0; i < it.NumField(); i++ { showTags := strings.Split(it.Type().Field(i).Tag.Get("show"), ",") - for _, showTag := range showTags { - if showTag == "noshow" { - bShow = false - } - } - - if bShow { - // TODO not the best way to use reflection. Check this later + if !utils.Contains(showTags, "noshow") { switch it.Field(i).Type().String() { case "json.RawMessage": - fmt.Fprintf(w, "%s:\t%s\n", it.Type().Field(i).Tag.Get("header"), it.Field(i).Interface()) + fmt.Fprintln(w, fmt.Sprintf("%s:\t%s", it.Type().Field(i).Tag.Get("header"), it.Field(i).Interface())) case "*json.RawMessage": - fmt.Fprintf(w, "%s:\t%s\n", it.Type().Field(i).Tag.Get("header"), it.Field(i).Elem()) + fmt.Fprintln(w, fmt.Sprintf("%s:\t%s", it.Type().Field(i).Tag.Get("header"), it.Field(i).Elem())) default: - fmt.Fprintf(w, "%s:\t%+v\n", it.Type().Field(i).Tag.Get("header"), it.Field(i).Interface()) + if it.Field(i).Kind() == reflect.Struct { + f.printItemAux(w, it.Field(i).Interface()) + } else { + fmt.Fprintln(w, fmt.Sprintf("%s:\t%+v", it.Type().Field(i).Tag.Get("header"), it.Field(i).Interface())) + } } } } - fmt.Fprintln(w) + return nil +} + +// PrintItem prints item +func (f *TextFormatter) PrintItem(item interface{}) error { + log.Debug("PrintItem") + + w := tabwriter.NewWriter(f.output, 15, 1, 3, ' ', 0) + f.printItemAux(w, item) w.Flush() return nil } +func (f *TextFormatter) printListHeadersAux(w *tabwriter.Writer, t reflect.Type) { + log.Debug("printListHeadersAux") + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + + if field.Type.Kind() == reflect.Struct { + f.printListHeadersAux(w, field.Type) + } + + showTags := strings.Split(field.Tag.Get("show"), ",") + if !utils.Contains(showTags, "nolist") { + fmt.Fprint(w, fmt.Sprintf("%+v\t", field.Tag.Get("header"))) + } + } +} + +func (f *TextFormatter) printListBodyAux(w *tabwriter.Writer, t reflect.Value) { + log.Debug("printListBodyAux") + + for i := 0; i < t.NumField(); i++ { + showTags := strings.Split(t.Type().Field(i).Tag.Get("show"), ",") + if !utils.Contains(showTags, "nolist") { + field := t.Field(i) + switch field.Type().String() { + case "json.RawMessage": + fmt.Fprint(w, fmt.Sprintf("%s\t", field.Interface())) + case "*json.RawMessage": + if field.IsNil() { + fmt.Fprint(w, fmt.Sprintf(" \t")) + } else { + fmt.Fprint(w, fmt.Sprintf("%s\t", field.Elem())) + } + default: + if field.Kind() == reflect.Struct { + f.printListBodyAux(w, field) + } else { + fmt.Fprint(w, fmt.Sprintf("%+v\t", field.Interface())) + } + } + } + } +} + // PrintList prints item list func (f *TextFormatter) PrintList(items interface{}) error { log.Debug("PrintList") @@ -70,82 +112,21 @@ func (f *TextFormatter) PrintList(items interface{}) error { its := reflect.ValueOf(items) t := its.Type().Kind() if t != reflect.Slice { - return fmt.Errorf("Couldn't print list. Expected slice, but received %s", t.String()) + return fmt.Errorf("couldn't print list. Expected slice, but received %s", t.String()) } w := tabwriter.NewWriter(f.output, 15, 1, 3, ' ', 0) - header := reflect.TypeOf(items).Elem() - nf := header.NumField() - - // avoid printing elements with 'show:nolist' attribute - // special format tags - avoid := make([]bool, nf) - format := make([]string, nf) - for i := 0; i < nf; i++ { - avoid[i] = false - showTags := strings.Split(header.Field(i).Tag.Get("show"), ",") - for _, showTag := range showTags { - if showTag == "nolist" { - avoid[i] = true - } - if showTag == minifySeconds { - format[i] = minifySeconds - } - } - } - - // print header - for i := 0; i < nf; i++ { - if !avoid[i] { - fmt.Fprintf(w, "%+v\t", header.Field(i).Tag.Get("header")) - } - } + // Headers + f.printListHeadersAux(w, reflect.TypeOf(items).Elem()) fmt.Fprintln(w) - // print contents - for i := 0; i < its.Len(); i++ { - it := its.Index(i) - nf := it.NumField() - for i := 0; i < nf; i++ { - if !avoid[i] { - - if format[i] == minifySeconds { - - remainingSeconds := int(it.Field(i).Float()) - s := remainingSeconds % 60 - remainingSeconds = (remainingSeconds - s) - m := int(remainingSeconds/60) % 60 - remainingSeconds = (remainingSeconds - m*60) - h := (remainingSeconds / 3600) % 24 - remainingSeconds = (remainingSeconds - h*3600) - d := int(remainingSeconds / 86400) - - if d > 0 { - fmt.Fprintf(w, "%dd%dh%dm\t", d, h, m) - } else { - fmt.Fprintf(w, "%dh%dm%ds\t", h, m, s) - } - - } else { - - switch it.Field(i).Type().String() { - case "json.RawMessage": - fmt.Fprintf(w, "%s\t", it.Field(i).Interface()) - case "*json.RawMessage": - if it.Field(i).IsNil() { - fmt.Fprintf(w, " \t") - } else { - fmt.Fprintf(w, "%s\t", it.Field(i).Elem()) - } - default: - fmt.Fprintf(w, "%+v\t", it.Field(i).Interface()) - } - } - } - } + // Body + for pos := 0; pos < its.Len(); pos++ { + f.printListBodyAux(w, its.Index(pos)) fmt.Fprintln(w) } + w.Flush() return nil From 5ff3a39e13a5f079a3363f47624d9d3640f0317d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Ban=CC=83os=20Lo=CC=81pez?= Date: Tue, 19 Mar 2019 11:12:22 +0100 Subject: [PATCH 08/30] Refactor to make better use of Labelable interface (issue #77) --- cmd/firewall_profiles_cmd.go | 42 ++++++++++++++-------------- cmd/labels_cmd.go | 53 ++++++++++++++---------------------- cmd/scripts_cmd.go | 36 +++++++++++++----------- cmd/servers_cmd.go | 50 ++++++++++++++++++++-------------- cmd/ssh_profiles_cmd.go | 40 ++++++++++++++------------- cmd/template_cmd.go | 38 ++++++++++++++------------ 6 files changed, 133 insertions(+), 126 deletions(-) diff --git a/cmd/firewall_profiles_cmd.go b/cmd/firewall_profiles_cmd.go index 4842732..a3595b1 100644 --- a/cmd/firewall_profiles_cmd.go +++ b/cmd/firewall_profiles_cmd.go @@ -1,6 +1,8 @@ package cmd import ( + "fmt" + "github.com/codegangsta/cli" "github.com/ingrammicro/concerto/api/network" "github.com/ingrammicro/concerto/api/types" @@ -39,25 +41,22 @@ func FirewallProfileList(c *cli.Context) error { formatter.PrintFatal("Couldn't receive firewallProfile data", err) } - labelables := make([]*types.LabelableFields, 0, len(firewallProfiles)) - for i := range firewallProfiles { - labelables = append(labelables, &firewallProfiles[i].LabelableFields) + labelables := make([]types.Labelable, len(firewallProfiles)) + for i, fwp := range firewallProfiles { + labelables[i] = types.Labelable(&fwp) } - - filteredLabelables := LabelFiltering(c, labelables) - - tmp := firewallProfiles - firewallProfiles = nil - if len(filteredLabelables) > 0 { - for _, labelable := range filteredLabelables { - for i := range tmp { - if &tmp[i].LabelableFields == labelable { - firewallProfiles = append(firewallProfiles, tmp[i]) - } - } + labelIDsByName, labelNamesByID := LabelLoadsMapping(c) + filteredLabelables := LabelFiltering(c, labelables, labelIDsByName) + LabelAssignNamesForIDs(c, filteredLabelables, labelNamesByID) + firewallProfiles = make([]types.FirewallProfile, len(filteredLabelables)) + for i, labelable := range labelables { + fw, ok := labelable.(*types.FirewallProfile) + if !ok { + formatter.PrintFatal("Label filtering returned unexpected result", + fmt.Errorf("expected labelable to be a *types.FirewallProfile, got a %T", labelable)) } + firewallProfiles[i] = *fw } - if err = formatter.PrintList(firewallProfiles); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -74,8 +73,8 @@ func FirewallProfileShow(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't receive firewallProfile data", err) } - - LabelAssignNamesForIDs(c, []*types.LabelableFields{&firewallProfile.LabelableFields}) + _, labelNamesByID := LabelLoadsMapping(c) + firewallProfile.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*firewallProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -109,8 +108,8 @@ func FirewallProfileCreate(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't create firewallProfile", err) } - - LabelAssignNamesForIDs(c, []*types.LabelableFields{&firewallProfile.LabelableFields}) + _, labelNamesByID := LabelLoadsMapping(c) + firewallProfile.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*firewallProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -132,7 +131,8 @@ func FirewallProfileUpdate(c *cli.Context) error { formatter.PrintFatal("Couldn't update firewallProfile", err) } - LabelAssignNamesForIDs(c, []*types.LabelableFields{&firewallProfile.LabelableFields}) + _, labelNamesByID := LabelLoadsMapping(c) + firewallProfile.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*firewallProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } diff --git a/cmd/labels_cmd.go b/cmd/labels_cmd.go index 245618e..e97c247 100644 --- a/cmd/labels_cmd.go +++ b/cmd/labels_cmd.go @@ -4,6 +4,7 @@ import ( "fmt" "regexp" "strings" + "github.com/codegangsta/cli" "github.com/ingrammicro/concerto/api/labels" "github.com/ingrammicro/concerto/api/types" @@ -66,49 +67,37 @@ func LabelCreate(c *cli.Context) error { // LabelFiltering subcommand function receives a collection of references to labelable objects // Evaluates the matching of assigned labels with the labels requested for filtering. -func LabelFiltering(c *cli.Context, inItems []*types.LabelableFields) []*types.LabelableFields { +func LabelFiltering(c *cli.Context, items []types.Labelable, labelIDsByName map[string]string) []types.Labelable { debugCmdFuncInfo(c) - labelsMapNameToID, labelsMapIDToName := LabelLoadsMapping(c) - - var outItems []*types.LabelableFields if c.String("labels") != "" { _, formatter := WireUpLabel(c) labelNamesIn := LabelsUnifyInputNames(c.String("labels"), formatter) - - var labelIDsIn []string + var filteringLabelIDs []string for _, name := range labelNamesIn { - labelIDsIn = append(labelIDsIn, labelsMapNameToID[name]) + id := labelIDsByName[name] + if id != "" { + filteringLabelIDs = append(filteringLabelIDs, id) + } } - - for i := 0; i < len(inItems); i++ { - if inItems[i].FilterByLabelIDs(labelIDsIn) { - // added filtered - outItems = append(outItems, inItems[i]) + var result []types.Labelable + for _, item := range items { + if item.FilterByLabelIDs(filteringLabelIDs) { + result = append(result, item) } } - } else { - // all included - outItems = inItems - } - - // Assigns the Labels names - for i := 0; i < len(outItems); i++ { - outItems[i].FillInLabelNames(labelsMapIDToName) + return result } - return outItems + return items } // LabelAssignNamesForIDs subcommand function receives a collection of references to labelables objects // Resolves the Labels names associated to a each resource from given Labels ids, loading object with respective labels names -func LabelAssignNamesForIDs(c *cli.Context, items []*types.LabelableFields) { +func LabelAssignNamesForIDs(c *cli.Context, items []types.Labelable, labelNamesByID map[string]string) { debugCmdFuncInfo(c) - - // Load Labels mapping ID <-> NAME - _, labelsMapIDToName := LabelLoadsMapping(c) - for i := 0; i < len(items); i++ { - items[i].FillInLabelNames(labelsMapIDToName) + for _, labelable := range items { + labelable.FillInLabelNames(labelNamesByID) } } @@ -122,14 +111,14 @@ func LabelLoadsMapping(c *cli.Context) (map[string]string, map[string]string) { formatter.PrintFatal("Couldn't receive labels data", err) } - labelsMapNameToID := make(map[string]string) - labelsMapIDToName := make(map[string]string) + labelIDsByName := make(map[string]string) + labelNamesByID := make(map[string]string) for _, label := range labels { - labelsMapNameToID[label.Name] = label.ID - labelsMapIDToName[label.ID] = label.Name + labelIDsByName[label.Name] = label.ID + labelNamesByID[label.ID] = label.Name } - return labelsMapNameToID, labelsMapIDToName + return labelIDsByName, labelNamesByID } // LabelsUnifyInputNames subcommand function evaluates the received labels names (comma separated string). diff --git a/cmd/scripts_cmd.go b/cmd/scripts_cmd.go index a194982..2711f3d 100644 --- a/cmd/scripts_cmd.go +++ b/cmd/scripts_cmd.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "strings" "github.com/codegangsta/cli" @@ -41,23 +42,23 @@ func ScriptsList(c *cli.Context) error { formatter.PrintFatal("Couldn't receive script data", err) } - labelables := make([]*types.LabelableFields, 0, len(scripts)) - for i := range scripts { - labelables = append(labelables, &scripts[i].LabelableFields) + labelables := make([]types.Labelable, len(scripts)) + for i, sc := range scripts { + labelables[i] = types.Labelable(&sc) } - filteredLabelables := LabelFiltering(c, labelables) + labelIDsByName, labelNamesByID := LabelLoadsMapping(c) + filteredLabelables := LabelFiltering(c, labelables, labelIDsByName) + LabelAssignNamesForIDs(c, filteredLabelables, labelNamesByID) - tmp := scripts - scripts = nil - if len(filteredLabelables) > 0 { - for _, labelable := range filteredLabelables { - for i := range tmp { - if &tmp[i].LabelableFields == labelable { - scripts = append(scripts, tmp[i]) - } - } + scripts = make([]types.Script, len(filteredLabelables)) + for i, labelable := range labelables { + fw, ok := labelable.(*types.Script) + if !ok { + formatter.PrintFatal("Label filtering returned unexpected result", + fmt.Errorf("expected labelable to be a *types.Script, got a %T", labelable)) } + scripts[i] = *fw } if err = formatter.PrintList(scripts); err != nil { @@ -77,7 +78,8 @@ func ScriptShow(c *cli.Context) error { formatter.PrintFatal("Couldn't receive script data", err) } - LabelAssignNamesForIDs(c, []*types.LabelableFields{&script.LabelableFields}) + _, labelNamesByID := LabelLoadsMapping(c) + script.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*script); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -108,7 +110,8 @@ func ScriptCreate(c *cli.Context) error { formatter.PrintFatal("Couldn't create script", err) } - LabelAssignNamesForIDs(c, []*types.LabelableFields{&script.LabelableFields}) + _, labelNamesByID := LabelLoadsMapping(c) + script.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*script); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -126,7 +129,8 @@ func ScriptUpdate(c *cli.Context) error { formatter.PrintFatal("Couldn't update script", err) } - LabelAssignNamesForIDs(c, []*types.LabelableFields{&script.LabelableFields}) + _, labelNamesByID := LabelLoadsMapping(c) + script.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*script); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } diff --git a/cmd/servers_cmd.go b/cmd/servers_cmd.go index 467060b..63a250f 100644 --- a/cmd/servers_cmd.go +++ b/cmd/servers_cmd.go @@ -1,6 +1,8 @@ package cmd import ( + "fmt" + "github.com/codegangsta/cli" "github.com/ingrammicro/concerto/api/cloud" "github.com/ingrammicro/concerto/api/types" @@ -39,25 +41,24 @@ func ServerList(c *cli.Context) error { formatter.PrintFatal("Couldn't receive server data", err) } - labelables := make([]*types.LabelableFields, 0, len(servers)) - for i := range servers { - labelables = append(labelables, &servers[i].LabelableFields) + labelables := make([]types.Labelable, len(servers)) + for i, server := range servers { + labelables[i] = types.Labelable(&server) } - filteredLabelables := LabelFiltering(c, labelables) + labelIDsByName, labelNamesByID := LabelLoadsMapping(c) + filteredLabelables := LabelFiltering(c, labelables, labelIDsByName) + LabelAssignNamesForIDs(c, filteredLabelables, labelNamesByID) - tmp := servers - servers = nil - if len(filteredLabelables) > 0 { - for _, labelable := range filteredLabelables { - for i := range tmp { - if &tmp[i].LabelableFields == labelable { - servers = append(servers, tmp[i]) - } - } + servers = make([]types.Server, len(filteredLabelables)) + for i, labelable := range labelables { + s, ok := labelable.(*types.Server) + if !ok { + formatter.PrintFatal("Label filtering returned unexpected result", + fmt.Errorf("expected labelable to be a *types.Server, got a %T", labelable)) } + servers[i] = *s } - if err = formatter.PrintList(servers); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -75,7 +76,8 @@ func ServerShow(c *cli.Context) error { formatter.PrintFatal("Couldn't receive server data", err) } - LabelAssignNamesForIDs(c, []*types.LabelableFields{&server.LabelableFields}) + _, labelNamesByID := LabelLoadsMapping(c) + server.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -107,7 +109,8 @@ func ServerCreate(c *cli.Context) error { formatter.PrintFatal("Couldn't create server", err) } - LabelAssignNamesForIDs(c, []*types.LabelableFields{&server.LabelableFields}) + _, labelNamesByID := LabelLoadsMapping(c) + server.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -125,7 +128,8 @@ func ServerUpdate(c *cli.Context) error { formatter.PrintFatal("Couldn't update server", err) } - LabelAssignNamesForIDs(c, []*types.LabelableFields{&server.LabelableFields}) + _, labelNamesByID := LabelLoadsMapping(c) + server.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -143,7 +147,8 @@ func ServerBoot(c *cli.Context) error { formatter.PrintFatal("Couldn't boot server", err) } - LabelAssignNamesForIDs(c, []*types.LabelableFields{&server.LabelableFields}) + _, labelNamesByID := LabelLoadsMapping(c) + server.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -161,7 +166,8 @@ func ServerReboot(c *cli.Context) error { formatter.PrintFatal("Couldn't reboot server", err) } - LabelAssignNamesForIDs(c, []*types.LabelableFields{&server.LabelableFields}) + _, labelNamesByID := LabelLoadsMapping(c) + server.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -179,7 +185,8 @@ func ServerShutdown(c *cli.Context) error { formatter.PrintFatal("Couldn't shutdown server", err) } - LabelAssignNamesForIDs(c, []*types.LabelableFields{&server.LabelableFields}) + _, labelNamesByID := LabelLoadsMapping(c) + server.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -197,7 +204,8 @@ func ServerOverride(c *cli.Context) error { formatter.PrintFatal("Couldn't override server", err) } - LabelAssignNamesForIDs(c, []*types.LabelableFields{&server.LabelableFields}) + _, labelNamesByID := LabelLoadsMapping(c) + server.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } diff --git a/cmd/ssh_profiles_cmd.go b/cmd/ssh_profiles_cmd.go index 7853ca9..8aa3e36 100644 --- a/cmd/ssh_profiles_cmd.go +++ b/cmd/ssh_profiles_cmd.go @@ -1,6 +1,8 @@ package cmd import ( + "fmt" + "github.com/codegangsta/cli" "github.com/ingrammicro/concerto/api/cloud" "github.com/ingrammicro/concerto/api/types" @@ -39,23 +41,21 @@ func SSHProfileList(c *cli.Context) error { formatter.PrintFatal("Couldn't receive sshProfile data", err) } - labelables := make([]*types.LabelableFields, 0, len(sshProfiles)) - for i := range sshProfiles { - labelables = append(labelables, &sshProfiles[i].LabelableFields) + labelables := make([]types.Labelable, len(sshProfiles)) + for i, sshP := range sshProfiles { + labelables[i] = types.Labelable(&sshP) } - - filteredLabelables := LabelFiltering(c, labelables) - - tmp := sshProfiles - sshProfiles = nil - if len(filteredLabelables) > 0 { - for _, labelable := range filteredLabelables { - for i := range tmp { - if &tmp[i].LabelableFields == labelable { - sshProfiles = append(sshProfiles, tmp[i]) - } - } + labelIDsByName, labelNamesByID := LabelLoadsMapping(c) + filteredLabelables := LabelFiltering(c, labelables, labelIDsByName) + LabelAssignNamesForIDs(c, filteredLabelables, labelNamesByID) + sshProfiles = make([]types.SSHProfile, len(filteredLabelables)) + for i, labelable := range labelables { + fw, ok := labelable.(*types.SSHProfile) + if !ok { + formatter.PrintFatal("Label filtering returned unexpected result", + fmt.Errorf("expected labelable to be a *types.SSHProfile, got a %T", labelable)) } + sshProfiles[i] = *fw } if err = formatter.PrintList(sshProfiles); err != nil { @@ -74,8 +74,8 @@ func SSHProfileShow(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't receive sshProfile data", err) } - - LabelAssignNamesForIDs(c, []*types.LabelableFields{&sshProfile.LabelableFields}) + _, labelNamesByID := LabelLoadsMapping(c) + sshProfile.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*sshProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -105,7 +105,8 @@ func SSHProfileCreate(c *cli.Context) error { formatter.PrintFatal("Couldn't create sshProfile", err) } - LabelAssignNamesForIDs(c, []*types.LabelableFields{&sshProfile.LabelableFields}) + _, labelNamesByID := LabelLoadsMapping(c) + sshProfile.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*sshProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -123,7 +124,8 @@ func SSHProfileUpdate(c *cli.Context) error { formatter.PrintFatal("Couldn't update sshProfile", err) } - LabelAssignNamesForIDs(c, []*types.LabelableFields{&sshProfile.LabelableFields}) + _, labelNamesByID := LabelLoadsMapping(c) + sshProfile.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*sshProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } diff --git a/cmd/template_cmd.go b/cmd/template_cmd.go index 4e97873..11f7375 100644 --- a/cmd/template_cmd.go +++ b/cmd/template_cmd.go @@ -1,6 +1,8 @@ package cmd import ( + "fmt" + "github.com/codegangsta/cli" "github.com/ingrammicro/concerto/api/blueprint" "github.com/ingrammicro/concerto/api/types" @@ -39,23 +41,22 @@ func TemplateList(c *cli.Context) error { formatter.PrintFatal("Couldn't receive template data", err) } - labelables := make([]*types.LabelableFields, 0, len(templates)) - for i := range templates { - labelables = append(labelables, &templates[i].LabelableFields) + labelables := make([]types.Labelable, len(templates)) + for i, t := range templates { + labelables[i] = types.Labelable(&t) } + labelIDsByName, labelNamesByID := LabelLoadsMapping(c) + filteredLabelables := LabelFiltering(c, labelables, labelIDsByName) + LabelAssignNamesForIDs(c, filteredLabelables, labelNamesByID) - filteredLabelables := LabelFiltering(c, labelables) - - tmp := templates - templates = nil - if len(filteredLabelables) > 0 { - for _, labelable := range filteredLabelables { - for i := range tmp { - if &tmp[i].LabelableFields == labelable { - templates = append(templates, tmp[i]) - } - } + templates = make([]types.Template, len(filteredLabelables)) + for i, labelable := range labelables { + fw, ok := labelable.(*types.Template) + if !ok { + formatter.PrintFatal("Label filtering returned unexpected result", + fmt.Errorf("expected labelable to be a *types.Template, got a %T", labelable)) } + templates[i] = *fw } if err = formatter.PrintList(templates); err != nil { @@ -75,7 +76,8 @@ func TemplateShow(c *cli.Context) error { formatter.PrintFatal("Couldn't receive template data", err) } - LabelAssignNamesForIDs(c, []*types.LabelableFields{&template.LabelableFields}) + _, labelNamesByID := LabelLoadsMapping(c) + template.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*template); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -111,7 +113,8 @@ func TemplateCreate(c *cli.Context) error { formatter.PrintFatal("Couldn't create template", err) } - LabelAssignNamesForIDs(c, []*types.LabelableFields{&template.LabelableFields}) + _, labelNamesByID := LabelLoadsMapping(c) + template.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*template); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } @@ -136,7 +139,8 @@ func TemplateUpdate(c *cli.Context) error { formatter.PrintFatal("Couldn't update template", err) } - LabelAssignNamesForIDs(c, []*types.LabelableFields{&template.LabelableFields}) + _, labelNamesByID := LabelLoadsMapping(c) + template.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*template); err != nil { formatter.PrintFatal("Couldn't print/format result", err) } From a8670ea641013a53619677dc47878e426435713d Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Tue, 19 Mar 2019 13:09:30 +0100 Subject: [PATCH 09/30] Fixed labelable interface resolution (issue #77) Closes #77 --- cmd/firewall_profiles_cmd.go | 13 ++++++++----- cmd/labels_cmd.go | 14 ++++++-------- cmd/scripts_cmd.go | 16 +++++++++------- cmd/servers_cmd.go | 14 ++++++-------- cmd/ssh_profiles_cmd.go | 16 +++++++++------- cmd/template_cmd.go | 15 ++++++++------- 6 files changed, 46 insertions(+), 42 deletions(-) diff --git a/cmd/firewall_profiles_cmd.go b/cmd/firewall_profiles_cmd.go index a3595b1..4c30d9f 100644 --- a/cmd/firewall_profiles_cmd.go +++ b/cmd/firewall_profiles_cmd.go @@ -42,14 +42,14 @@ func FirewallProfileList(c *cli.Context) error { } labelables := make([]types.Labelable, len(firewallProfiles)) - for i, fwp := range firewallProfiles { - labelables[i] = types.Labelable(&fwp) + for i:=0; i< len(firewallProfiles); i++ { + labelables[i] = types.Labelable(&firewallProfiles[i]) } labelIDsByName, labelNamesByID := LabelLoadsMapping(c) filteredLabelables := LabelFiltering(c, labelables, labelIDsByName) LabelAssignNamesForIDs(c, filteredLabelables, labelNamesByID) firewallProfiles = make([]types.FirewallProfile, len(filteredLabelables)) - for i, labelable := range labelables { + for i, labelable := range filteredLabelables { fw, ok := labelable.(*types.FirewallProfile) if !ok { formatter.PrintFatal("Label filtering returned unexpected result", @@ -99,8 +99,11 @@ func FirewallProfileCreate(c *cli.Context) error { if c.String("rules") != "" { firewallProfileIn["rules"] = (*params)["rules"] } + + labelIDsByName, labelNamesByID := LabelLoadsMapping(c) + if c.IsSet("labels") { - labelsIdsArr := LabelResolution(c, c.String("labels")) + labelsIdsArr := LabelResolution(c, c.String("labels"), labelIDsByName) firewallProfileIn["label_ids"] = labelsIdsArr } @@ -108,7 +111,7 @@ func FirewallProfileCreate(c *cli.Context) error { if err != nil { formatter.PrintFatal("Couldn't create firewallProfile", err) } - _, labelNamesByID := LabelLoadsMapping(c) + firewallProfile.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*firewallProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) diff --git a/cmd/labels_cmd.go b/cmd/labels_cmd.go index e97c247..95a68ac 100644 --- a/cmd/labels_cmd.go +++ b/cmd/labels_cmd.go @@ -76,9 +76,7 @@ func LabelFiltering(c *cli.Context, items []types.Labelable, labelIDsByName map[ var filteringLabelIDs []string for _, name := range labelNamesIn { id := labelIDsByName[name] - if id != "" { - filteringLabelIDs = append(filteringLabelIDs, id) - } + filteringLabelIDs = append(filteringLabelIDs, id) } var result []types.Labelable for _, item := range items { @@ -137,18 +135,17 @@ func LabelsUnifyInputNames(labelsNames string, formatter format.Formatter) []str // LabelResolution subcommand function retrieves a labels map(Name<->ID) based on label names received to be processed. // The function evaluates the received labels names (comma separated string); with them, solves the assigned IDs for the given labels names. // If the label name is not available in IMCO yet, it is created. -func LabelResolution(c *cli.Context, labelsNames string) []string { +func LabelResolution(c *cli.Context, labelsNames string, labelIDsByName map[string]string) []string { debugCmdFuncInfo(c) labelsSvc, formatter := WireUpLabel(c) labelNamesIn := LabelsUnifyInputNames(labelsNames, formatter) - labelsMapNameToID, _ := LabelLoadsMapping(c) // Obtain output mapped labels Name<->ID; currently in IMCO platform as well as if creation is required labelsOutMap := make(map[string]string) for _, name := range labelNamesIn { // check if the label already exists in IMCO, creates it if it does not exist - if labelsMapNameToID[name] == "" { + if labelIDsByName[name] == "" { labelPayload := make(map[string]interface{}) labelPayload["name"] = name newLabel, err := labelsSvc.CreateLabel(&labelPayload) @@ -157,7 +154,7 @@ func LabelResolution(c *cli.Context, labelsNames string) []string { } labelsOutMap[name] = newLabel.ID } else { - labelsOutMap[name] = labelsMapNameToID[name] + labelsOutMap[name] = labelIDsByName[name] } } labelsIdsArr := make([]string, 0) @@ -174,7 +171,8 @@ func LabelAdd(c *cli.Context) error { labelsSvc, formatter := WireUpLabel(c) checkRequiredFlags(c, []string{"id", "label"}, formatter) - labelsIdsArr := LabelResolution(c, c.String("label")) + labelIDsByName, _ := LabelLoadsMapping(c) + labelsIdsArr := LabelResolution(c, c.String("label"), labelIDsByName) if len(labelsIdsArr) > 1 { formatter.PrintFatal("Too many label names. Please, Use only one label name", fmt.Errorf("Invalid parameter: %v - %v", c.String("label"), labelsIdsArr)) } diff --git a/cmd/scripts_cmd.go b/cmd/scripts_cmd.go index 2711f3d..0e1386e 100644 --- a/cmd/scripts_cmd.go +++ b/cmd/scripts_cmd.go @@ -43,8 +43,8 @@ func ScriptsList(c *cli.Context) error { } labelables := make([]types.Labelable, len(scripts)) - for i, sc := range scripts { - labelables[i] = types.Labelable(&sc) + for i:=0; i< len(scripts); i++ { + labelables[i] = types.Labelable(&scripts[i]) } labelIDsByName, labelNamesByID := LabelLoadsMapping(c) @@ -52,13 +52,13 @@ func ScriptsList(c *cli.Context) error { LabelAssignNamesForIDs(c, filteredLabelables, labelNamesByID) scripts = make([]types.Script, len(filteredLabelables)) - for i, labelable := range labelables { - fw, ok := labelable.(*types.Script) + for i, labelable := range filteredLabelables { + s, ok := labelable.(*types.Script) if !ok { formatter.PrintFatal("Label filtering returned unexpected result", fmt.Errorf("expected labelable to be a *types.Script, got a %T", labelable)) } - scripts[i] = *fw + scripts[i] = *s } if err = formatter.PrintList(scripts); err != nil { @@ -100,8 +100,11 @@ func ScriptCreate(c *cli.Context) error { if c.String("parameters") != "" { scriptIn["parameters"] = strings.Split(c.String("parameters"), ",") } + + labelIDsByName, labelNamesByID := LabelLoadsMapping(c) + if c.IsSet("labels") { - labelsIdsArr := LabelResolution(c, c.String("labels")) + labelsIdsArr := LabelResolution(c, c.String("labels"), labelIDsByName) scriptIn["label_ids"] = labelsIdsArr } @@ -110,7 +113,6 @@ func ScriptCreate(c *cli.Context) error { formatter.PrintFatal("Couldn't create script", err) } - _, labelNamesByID := LabelLoadsMapping(c) script.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*script); err != nil { formatter.PrintFatal("Couldn't print/format result", err) diff --git a/cmd/servers_cmd.go b/cmd/servers_cmd.go index 63a250f..64267ec 100644 --- a/cmd/servers_cmd.go +++ b/cmd/servers_cmd.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "github.com/codegangsta/cli" "github.com/ingrammicro/concerto/api/cloud" "github.com/ingrammicro/concerto/api/types" @@ -42,16 +41,14 @@ func ServerList(c *cli.Context) error { } labelables := make([]types.Labelable, len(servers)) - for i, server := range servers { - labelables[i] = types.Labelable(&server) + for i:=0; i< len(servers); i++ { + labelables[i] = types.Labelable(&servers[i]) } - labelIDsByName, labelNamesByID := LabelLoadsMapping(c) filteredLabelables := LabelFiltering(c, labelables, labelIDsByName) LabelAssignNamesForIDs(c, filteredLabelables, labelNamesByID) - servers = make([]types.Server, len(filteredLabelables)) - for i, labelable := range labelables { + for i, labelable := range filteredLabelables { s, ok := labelable.(*types.Server) if !ok { formatter.PrintFatal("Label filtering returned unexpected result", @@ -99,8 +96,10 @@ func ServerCreate(c *cli.Context) error { "cloud_account_id": c.String("cloud_account_id"), } + labelIDsByName, labelNamesByID := LabelLoadsMapping(c) + if c.IsSet("labels") { - labelsIdsArr := LabelResolution(c, c.String("labels")) + labelsIdsArr := LabelResolution(c, c.String("labels"), labelIDsByName) serverIn["label_ids"] = labelsIdsArr } @@ -109,7 +108,6 @@ func ServerCreate(c *cli.Context) error { formatter.PrintFatal("Couldn't create server", err) } - _, labelNamesByID := LabelLoadsMapping(c) server.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*server); err != nil { formatter.PrintFatal("Couldn't print/format result", err) diff --git a/cmd/ssh_profiles_cmd.go b/cmd/ssh_profiles_cmd.go index 8aa3e36..a3fb548 100644 --- a/cmd/ssh_profiles_cmd.go +++ b/cmd/ssh_profiles_cmd.go @@ -42,20 +42,20 @@ func SSHProfileList(c *cli.Context) error { } labelables := make([]types.Labelable, len(sshProfiles)) - for i, sshP := range sshProfiles { - labelables[i] = types.Labelable(&sshP) + for i:=0; i< len(sshProfiles); i++ { + labelables[i] = types.Labelable(&sshProfiles[i]) } labelIDsByName, labelNamesByID := LabelLoadsMapping(c) filteredLabelables := LabelFiltering(c, labelables, labelIDsByName) LabelAssignNamesForIDs(c, filteredLabelables, labelNamesByID) sshProfiles = make([]types.SSHProfile, len(filteredLabelables)) - for i, labelable := range labelables { - fw, ok := labelable.(*types.SSHProfile) + for i, labelable := range filteredLabelables { + sshP, ok := labelable.(*types.SSHProfile) if !ok { formatter.PrintFatal("Label filtering returned unexpected result", fmt.Errorf("expected labelable to be a *types.SSHProfile, got a %T", labelable)) } - sshProfiles[i] = *fw + sshProfiles[i] = *sshP } if err = formatter.PrintList(sshProfiles); err != nil { @@ -95,8 +95,11 @@ func SSHProfileCreate(c *cli.Context) error { if c.String("private_key") != "" { sshProfileIn["private_key"] = c.String("private_key") } + + labelIDsByName, labelNamesByID := LabelLoadsMapping(c) + if c.IsSet("labels") { - labelsIdsArr := LabelResolution(c, c.String("labels")) + labelsIdsArr := LabelResolution(c, c.String("labels"), labelIDsByName) sshProfileIn["label_ids"] = labelsIdsArr } @@ -105,7 +108,6 @@ func SSHProfileCreate(c *cli.Context) error { formatter.PrintFatal("Couldn't create sshProfile", err) } - _, labelNamesByID := LabelLoadsMapping(c) sshProfile.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*sshProfile); err != nil { formatter.PrintFatal("Couldn't print/format result", err) diff --git a/cmd/template_cmd.go b/cmd/template_cmd.go index 11f7375..9c39cdf 100644 --- a/cmd/template_cmd.go +++ b/cmd/template_cmd.go @@ -42,21 +42,21 @@ func TemplateList(c *cli.Context) error { } labelables := make([]types.Labelable, len(templates)) - for i, t := range templates { - labelables[i] = types.Labelable(&t) + for i:=0; i< len(templates); i++ { + labelables[i] = types.Labelable(&templates[i]) } labelIDsByName, labelNamesByID := LabelLoadsMapping(c) filteredLabelables := LabelFiltering(c, labelables, labelIDsByName) LabelAssignNamesForIDs(c, filteredLabelables, labelNamesByID) templates = make([]types.Template, len(filteredLabelables)) - for i, labelable := range labelables { - fw, ok := labelable.(*types.Template) + for i, labelable := range filteredLabelables { + tpl, ok := labelable.(*types.Template) if !ok { formatter.PrintFatal("Label filtering returned unexpected result", fmt.Errorf("expected labelable to be a *types.Template, got a %T", labelable)) } - templates[i] = *fw + templates[i] = *tpl } if err = formatter.PrintList(templates); err != nil { @@ -103,8 +103,10 @@ func TemplateCreate(c *cli.Context) error { "configuration_attributes": (*params)["configuration_attributes"], } + labelIDsByName, labelNamesByID := LabelLoadsMapping(c) + if c.IsSet("labels") { - labelsIdsArr := LabelResolution(c, c.String("labels")) + labelsIdsArr := LabelResolution(c, c.String("labels"), labelIDsByName) templateIn["label_ids"] = labelsIdsArr } @@ -113,7 +115,6 @@ func TemplateCreate(c *cli.Context) error { formatter.PrintFatal("Couldn't create template", err) } - _, labelNamesByID := LabelLoadsMapping(c) template.FillInLabelNames(labelNamesByID) if err = formatter.PrintItem(*template); err != nil { formatter.PrintFatal("Couldn't print/format result", err) From 715e84ca39d07db3bb305ea44c1f225d199e6950 Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Tue, 19 Feb 2019 15:34:42 +0100 Subject: [PATCH 10/30] Initial commit (issue #90) --- api/blueprint/bootstrapping_api.go | 93 +++++ api/blueprint/bootstrapping_api_mocked.go | 3 + api/blueprint/bootstrapping_api_test.go | 16 + api/types/bootstrapping.go | 28 ++ bootstrapping/bootstrapping.go | 428 ++++++++++++++++++++++ bootstrapping/subcommands.go | 27 ++ cmd/bootstrapping_cmd.go | 29 ++ main.go | 9 + utils/utils.go | 42 +++ utils/webservice.go | 23 +- utils/webservice_mock.go | 2 +- 11 files changed, 690 insertions(+), 10 deletions(-) create mode 100644 api/blueprint/bootstrapping_api.go create mode 100644 api/blueprint/bootstrapping_api_mocked.go create mode 100644 api/blueprint/bootstrapping_api_test.go create mode 100644 api/types/bootstrapping.go create mode 100644 bootstrapping/bootstrapping.go create mode 100644 bootstrapping/subcommands.go create mode 100644 cmd/bootstrapping_cmd.go diff --git a/api/blueprint/bootstrapping_api.go b/api/blueprint/bootstrapping_api.go new file mode 100644 index 0000000..fe45013 --- /dev/null +++ b/api/blueprint/bootstrapping_api.go @@ -0,0 +1,93 @@ +package blueprint + +import ( + "encoding/json" + "fmt" + + log "github.com/Sirupsen/logrus" + "github.com/ingrammicro/concerto/api/types" + "github.com/ingrammicro/concerto/utils" +) + + +// BootstrappingService manages bootstrapping operations +type BootstrappingService struct { + concertoService utils.ConcertoService +} + +// NewBootstrappingService returns a bootstrapping service +func NewBootstrappingService(concertoService utils.ConcertoService) (*BootstrappingService, error) { + if concertoService == nil { + return nil, fmt.Errorf("must initialize ConcertoService before using it") + } + + return &BootstrappingService{ + concertoService: concertoService, + }, nil + +} + +// GetBootstrappingConfiguration returns the list of policy files as a JSON response with the desired configuration changes +func (bs *BootstrappingService) GetBootstrappingConfiguration() (bootstrappingConfigurations *types.BootstrappingConfiguration, status int, err error) { + log.Debug("GetBootstrappingConfiguration") + + data, status, err := bs.concertoService.Get("/blueprint/configuration") + if err != nil { + return nil, status, err + } + + if err = utils.CheckStandardStatus(status, data); err != nil { + return nil, status, err + } + + if err = json.Unmarshal(data, &bootstrappingConfigurations); err != nil { + return nil, status, err + } + + return bootstrappingConfigurations, status, nil +} + +// ReportBootstrappingAppliedConfiguration +func (bs *BootstrappingService) ReportBootstrappingAppliedConfiguration(BootstrappingAppliedConfigurationVector *map[string]interface{}) (err error) { + log.Debug("ReportBootstrappingAppliedConfiguration") + + data, status, err := bs.concertoService.Put("/blueprint/applied_configuration", BootstrappingAppliedConfigurationVector) + if err != nil { + return err + } + + if err = utils.CheckStandardStatus(status, data); err != nil { + return err + } + + return nil +} + +// ReportBootstrappingLog reports a policy files application result +func (bs *BootstrappingService) ReportBootstrappingLog(BootstrappingContinuousReportVector *map[string]interface{}) (command *types.BootstrappingContinuousReport, status int, err error) { + log.Debug("ReportBootstrappingLog") + + data, status, err := bs.concertoService.Post("/blueprint/bootstrap_logs", BootstrappingContinuousReportVector) + if err != nil { + return nil, status, err + } + + if err = json.Unmarshal(data, &command); err != nil { + return nil, status, err + } + + return command, status, nil +} + + +// +func (bs *BootstrappingService) DownloadPolicyFile(url string, filePath string) (realFileName string, status int, err error) { + log.Debug("DownloadPolicyFile") + + realFileName, status, err = bs.concertoService.GetFile(url, "", filePath) + if err != nil { + return realFileName, status, err + } + + return realFileName, status, nil +} \ No newline at end of file diff --git a/api/blueprint/bootstrapping_api_mocked.go b/api/blueprint/bootstrapping_api_mocked.go new file mode 100644 index 0000000..28bf3c3 --- /dev/null +++ b/api/blueprint/bootstrapping_api_mocked.go @@ -0,0 +1,3 @@ +package blueprint + +// TODO diff --git a/api/blueprint/bootstrapping_api_test.go b/api/blueprint/bootstrapping_api_test.go new file mode 100644 index 0000000..74ba96c --- /dev/null +++ b/api/blueprint/bootstrapping_api_test.go @@ -0,0 +1,16 @@ +package blueprint + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewBootstrappingServiceNil(t *testing.T) { + assert := assert.New(t) + rs, err := NewBootstrappingService(nil) + assert.Nil(rs, "Uninitialized service should return nil") + assert.NotNil(err, "Uninitialized service should return error") +} + +// TODO diff --git a/api/types/bootstrapping.go b/api/types/bootstrapping.go new file mode 100644 index 0000000..76e9918 --- /dev/null +++ b/api/types/bootstrapping.go @@ -0,0 +1,28 @@ +package types + +import ( + "encoding/json" +) + +type BootstrappingConfiguration struct { + PolicyFiles []BootstrappingPolicyFile `json:"policyfiles,omitempty" header:"POLICY FILES" show:"nolist"` + Attributes *json.RawMessage `json:"attributes,omitempty" header:"ATTRIBUTES" show:"nolist"` + AttributeRevisionID string `json:"attribute_revision_id,omitempty" header:"ATTRIBUTE REVISION ID"` +} + +type BootstrappingPolicyFile struct { + ID string `json:"id,omitempty" header:"ID"` + RevisionID string `json:"revision_id,omitempty" header:"REVISION ID"` + DownloadURL string `json:"download_url,omitempty" header:"DOWNLOAD URL"` +} + +type BootstrappingContinuousReport struct { + Stdout string `json:"stdout" header:"STDOUT"` +} + +type BootstrappingAppliedConfiguration struct { + StartedAt string `json:"started_at,omitempty" header:"STARTED AT"` + FinishedAt string `json:"finished_at,omitempty" header:"FINISHED AT"` + PolicyFileRevisionIDs string `json:"policyfile_revision_ids,omitempty" header:"POLICY FILE REVISION IDS" show:"nolist"` + AttributeRevisionID string `json:"attribute_revision_id,omitempty" header:"ATTRIBUTE REVISION ID"` +} diff --git a/bootstrapping/bootstrapping.go b/bootstrapping/bootstrapping.go new file mode 100644 index 0000000..2128403 --- /dev/null +++ b/bootstrapping/bootstrapping.go @@ -0,0 +1,428 @@ +package bootstrapping + +import ( + "context" + "encoding/json" + "io/ioutil" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + "time" + "math/rand" + + log "github.com/Sirupsen/logrus" + "github.com/codegangsta/cli" + "github.com/ingrammicro/concerto/api/blueprint" + "github.com/ingrammicro/concerto/api/types" + "github.com/ingrammicro/concerto/cmd" + "github.com/ingrammicro/concerto/utils" + "github.com/ingrammicro/concerto/utils/format" + "fmt" +) + +const ( + // DefaultTimingInterval Default period for looping + DefaultTimingInterval = 600 // 600 seconds = 10 minutes + DefaultRandomMaxThreshold = 6 // minutes + + // ProcessIDFile + ProcessIDFile = "imco-bootstrapping.pid" + + RetriesNumber = 5 + RetriesFactor = 3 + DefaultThresholdTime = 10 +) + +type bootstrappingStatus struct { + startedAt string + finishedAt string + policiesStatus []policyStatus + attributes attributesStatus +} +type attributesStatus struct { + revisionID string + filename string + filePath string + rawData *json.RawMessage +} + +type policyStatus struct { + id string + revisionID string + name string + filename string + tarballURL string + queryURL string + tarballPath string + folderPath string + + downloaded bool + uncompressed bool + executed bool + logged bool +} + +// Handle signals +func handleSysSignals(cancelFunc context.CancelFunc) { + log.Debug("handleSysSignals") + + gracefulStop := make(chan os.Signal, 1) + signal.Notify(gracefulStop, syscall.SIGTERM, syscall.SIGINT, syscall.SIGKILL) + log.Debug("Ending, signal detected:", <-gracefulStop) + cancelFunc() +} + +// Returns the full path to the tmp folder joined with pid management file name +func getProcessIDFilePath() string { + return strings.Join([]string{os.TempDir(), string(os.PathSeparator), ProcessIDFile}, "") +} + +// Returns the full path to the tmp folder +func getProcessingFolderFilePath() string { + dir := strings.Join([]string{os.TempDir(), string(os.PathSeparator), "imco", string(os.PathSeparator)}, "") + os.Mkdir(dir, 0777) + return dir +} + +// Start the bootstrapping process +func start(c *cli.Context) error { + log.Debug("start") + + formatter := format.GetFormatter() + if err := utils.SetProcessIdToFile(getProcessIDFilePath()); err != nil { + formatter.PrintFatal("cannot create the pid file", err) + } + + timingInterval := c.Int64("time") + if !(timingInterval > 0) { + timingInterval = DefaultTimingInterval + } + // Adds a random value to the given timing interval! + // Sleep for a configured amount of time plus a random amount of time (10 minutes plus 0 to 5 minutes, for instance) + timingInterval = timingInterval + int64(rand.New(rand.NewSource(time.Now().UnixNano())).Intn(DefaultRandomMaxThreshold)*60) + log.Debug("time interval:", timingInterval) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go handleSysSignals(cancel) + + bootstrappingRoutine(ctx, c, timingInterval) + + return nil +} + +// Stop the bootstrapping process +func stop(c *cli.Context) error { + log.Debug("cmdStop") + + formatter := format.GetFormatter() + if err := utils.StopProcess(getProcessIDFilePath()); err != nil { + formatter.PrintFatal("cannot stop the bootstrapping process", err) + } + + log.Info("Bootstrapping routine successfully stopped") + return nil +} + +// Main bootstrapping background routine +func bootstrappingRoutine(ctx context.Context, c *cli.Context, timingInterval int64) { + log.Debug("bootstrappingRoutine") + + //formatter := format.GetFormatter() + bootstrappingSvc, formatter := cmd.WireUpBootstrapping(c) + commandProcessed := make(chan bool, 1) + + // initialization + currentTicker := time.NewTicker(time.Duration(timingInterval) * time.Second) + for { + go processingCommandRoutine(bootstrappingSvc, formatter, commandProcessed) + + log.Debug("Waiting...", currentTicker) + + select { + //case <-commandProcessed: + // isRunningCommandRoutine = false + // log.Debug("commandProcessed") + case <-currentTicker.C: + log.Debug("ticker") + case <-ctx.Done(): + log.Debug(ctx.Err()) + log.Debug("closing bootstrapping") + return + } + } +} + +// Subsidiary routine for commands processing +func processingCommandRoutine(bootstrappingSvc *blueprint.BootstrappingService, formatter format.Formatter, commandProcessed chan bool) { + log.Debug("processingCommandRoutine") + + // Inquire about desired configuration changes to be applied by querying the `GET /blueprint/configuration` endpoint. This will provide a JSON response with the desired configuration changes + bsConfiguration, status, err := bootstrappingSvc.GetBootstrappingConfiguration() + if err != nil { + formatter.PrintError("Couldn't receive bootstrapping data", err) + } else { + if status == 200 { + bsStatus := new(bootstrappingStatus) + directoryPath := getProcessingFolderFilePath() + + // proto structures + if err := initializePrototype(directoryPath, bsConfiguration, bsStatus); err != nil { + formatter.PrintError("Cannot initialize the policy files prototypes", err) + } + + // TODO Currently as a step previous to process tarballs policies but this can be done as a part or processing, and using defer for removing files (tgz & folder!?) + // For every policyFile, ensure its tarball (downloadable through their download_url) has been downloaded to the server ... + if err := downloadPolicyFiles(bootstrappingSvc, bsStatus); err != nil { + formatter.PrintError("Cannot download the policy files", err) + } + + //... and clean off any tarball that is no longer needed. + if err := cleanObsoletePolicyFiles(directoryPath, bsStatus); err != nil { + formatter.PrintError("Cannot clean obsolete policy files", err) + } + + // Store the attributes as JSON in a file with name `attrs-.json` + if err := saveAttributes(bsStatus); err != nil { + formatter.PrintError("Cannot save policy files attributes ", err) + } + + // Process tarballs policies + if err := processPolicyFiles(bootstrappingSvc, bsStatus); err != nil { + formatter.PrintError("Cannot process policy files ", err) + } + + // Inform the platform of applied changes via a `PUT /blueprint/applied_configuration` request with a JSON payload similar to + reportAppliedConfiguration(bootstrappingSvc, bsStatus) + } + } + + // TODO + commandProcessed <- true +} + +func initializePrototype(directoryPath string, bsConfiguration *types.BootstrappingConfiguration, bsStatus *bootstrappingStatus) error { + log.Debug("initializePrototype") + + log.Debug("Initializing bootstrapping structures") + + bsStatus.startedAt = time.Now().UTC().String() // TODO UTC? + + // Attributes + bsStatus.attributes.revisionID = bsConfiguration.AttributeRevisionID + bsStatus.attributes.filename = strings.Join([]string{"attrs-", bsStatus.attributes.revisionID, ".json"}, "") + bsStatus.attributes.filePath = strings.Join([]string{directoryPath, bsStatus.attributes.filename}, "") + bsStatus.attributes.rawData = bsConfiguration.Attributes + + // Policies + for _, policyFile := range bsConfiguration.PolicyFiles { + policyStatus := new(policyStatus) + policyStatus.id = policyFile.ID + policyStatus.revisionID = policyFile.RevisionID + policyStatus.name = strings.Join([]string{policyFile.ID, "-", policyFile.RevisionID}, "") + policyStatus.filename = strings.Join([]string{policyStatus.name, ".tgz"}, "") + policyStatus.tarballURL = policyFile.DownloadURL + + url, err := url.Parse(policyStatus.tarballURL) + if err != nil { + // TODO should it be an error? + return err + } + policyStatus.queryURL = strings.Join([]string{url.Path[1:], url.RawQuery}, "?") + + policyStatus.tarballPath = strings.Join([]string{directoryPath, policyStatus.filename}, "") + policyStatus.folderPath = strings.Join([]string{directoryPath, policyStatus.name}, "") + + bsStatus.policiesStatus = append(bsStatus.policiesStatus, *policyStatus) + } + log.Debug(bsStatus) + return nil +} + +// downloadPolicyFiles For every policy file, ensure its tarball (downloadable through their download_url) has been downloaded to the server ... +func downloadPolicyFiles(bootstrappingSvc *blueprint.BootstrappingService, bsStatus *bootstrappingStatus) error { + log.Debug("downloadPolicyFiles") + + for _, policyStatus := range bsStatus.policiesStatus { + log.Debug("Downloading: ", policyStatus.tarballURL) + _, status, err := bootstrappingSvc.DownloadPolicyFile(policyStatus.queryURL, policyStatus.tarballPath) + if err != nil { + return err + } + if status == 200 { + policyStatus.downloaded = true + log.Debug("Uncompressing: ", policyStatus.tarballPath) + if err = utils.Untar(policyStatus.tarballPath, policyStatus.folderPath); err != nil { + return err + } + policyStatus.uncompressed = true + } else { + // TODO should it be an error? + log.Error("Cannot download the policy file: ", policyStatus.filename) + } + } + return nil +} + +// cleanObsoletePolicyFiles cleans off any tarball that is no longer needed. +func cleanObsoletePolicyFiles(directoryPath string, bsStatus *bootstrappingStatus) error { + log.Debug("cleanObsoletePolicyFiles") + + // builds an array of currently processable files at this looping time + currentlyProcessableFiles := []string{bsStatus.attributes.filename} // saved attributes file name + for _, policyStatus := range bsStatus.policiesStatus { + currentlyProcessableFiles = append(currentlyProcessableFiles, policyStatus.filename) // Downloaded tgz file names + currentlyProcessableFiles = append(currentlyProcessableFiles, policyStatus.name) // Uncompressed folder names + } + + // evaluates working folder + files, err := ioutil.ReadDir(directoryPath) + if err != nil { + // TODO should it be an error? + log.Warn("Cannot read directory: ", directoryPath, err) + } + + // removes files not regarding to any of current policy files + for _, f := range files { + if !utils.Contains(currentlyProcessableFiles, f.Name()) { + log.Debug("Removing: ", f.Name()) + if err := utils.RemoveFileInfo(f, strings.Join([]string{directoryPath, string(os.PathSeparator), f.Name()}, "")); err != nil { + // TODO should it be an error? + log.Warn("Cannot remove: ", f.Name(), err) + } + } + } + return nil // TODO should it be managed as error? +} + +// saveAttributes stores the attributes as JSON in a file with name `attrs-.json` +func saveAttributes(bsStatus *bootstrappingStatus) error { + log.Debug("saveAttributes") + + attrs, err := json.Marshal(bsStatus.attributes.rawData) + if err != nil { + return err + } + if err := ioutil.WriteFile(bsStatus.attributes.filePath, attrs, 0600); err != nil { + return err + } + return nil +} + + +//For every policy file, apply them doing the following: +// * Extract the tarball to a temporal work directory DIR +// * Run `cd DIR; chef-client -z -j path/to/attrs-.json` while sending the stderr and stdout in bunches of 10 lines to the +// platform via `POST /blueprint/bootstrap_logs` (this resource is a copy of POST /command_polling/bootstrap_logs used in the command_polling command). +// If the command returns with a non-zero value, stop applying policy files and continue with the next step. + +// TODO On the first iteration that applies successfully all policy files (runs all `chef-client -z` commands obtaining 0 return codes) only, run the boot scripts for the server by executing the `scripts boot` sub-command (as an external process). +// TODO Just a POC, an starging point. To be completed... +func processPolicyFiles(bootstrappingSvc *blueprint.BootstrappingService, bsStatus *bootstrappingStatus) error { + log.Debug("processPolicyFiles") + + // Run `cd DIR; chef-client -z -j path/to/attrs-.json` while sending the stderr and stdout in bunches of + // 10 lines to the platform via `POST /blueprint/bootstrap_logs` (this resource is a copy of POST /command_polling/bootstrap_logs used in + // the command_polling command). If the command returns with a non-zero value, stop applying policyfiles and continue with the next step. + for _, policyStatus := range bsStatus.policiesStatus { + log.Warn(policyStatus.folderPath) + + // TODO cd ; chef-client -z -j ` + command := "ping -c 100 8.8.8.8" + + // cli command threshold flag + thresholdTime := DefaultThresholdTime + log.Debug("Time threshold: ", thresholdTime) + + // Custom method for chunks processing + fn := func(chunk string) error { + log.Debug("sendChunks") + err := retry(RetriesNumber, time.Second, func() error { + log.Debug("Sending: ", chunk) + + commandIn := map[string]interface{}{ + "stdout": chunk, + } + + _, statusCode, err := bootstrappingSvc.ReportBootstrappingLog(&commandIn) + switch { + // 0<100 error cases?? + case statusCode == 0: + return fmt.Errorf("communication error %v %v", statusCode, err) + case statusCode >= 500: + return fmt.Errorf("server error %v %v", statusCode, err) + case statusCode >= 400: + return fmt.Errorf("client error %v %v", statusCode, err) + default: + return nil + } + }) + + if err != nil { + return fmt.Errorf("cannot send the chunk data, %v", err) + } + return nil + } + + // TODO This method was implemented in some moment based on nLines, nTime, bBytes? Currently only working with thresholdTime + exitCode, err := utils.RunContinuousCmd(fn, command, thresholdTime) + if err != nil { + log.Error("cannot process continuous report command", err) + } + + log.Info("completed: ", exitCode) + } + return nil +} + +func retry(attempts int, sleep time.Duration, fn func() error) error { + log.Debug("retry") + + if err := fn(); err != nil { + if attempts--; attempts > 0 { + log.Debug("Waiting to retry: ", sleep) + time.Sleep(sleep) + return retry(attempts, RetriesFactor*sleep, fn) + } + return err + } + return nil +} + +// reportAppliedConfiguration Inform the platform of applied changes via a `PUT /blueprint/applied_configuration` request +//The `policy file_revision_ids` field should have revision ids set only for those policy files successfully applied on the iteration, that is, +// it should not have any values set for those failing and those skipped because of a previous one failing. +func reportAppliedConfiguration(bootstrappingSvc *blueprint.BootstrappingService, bsStatus *bootstrappingStatus) error { + log.Debug("reportAppliedConfiguration") + + bsStatus.finishedAt = time.Now().UTC().String() // TODO UTC? + + var policyfileRevisionIDs string + for _, policyStatus := range bsStatus.policiesStatus { + if policyStatus.executed { // only for policies successfully applied + appliedPolicyMap := map[string]string{policyStatus.id: policyStatus.revisionID} + appliedPolicyBytes, err := json.Marshal(appliedPolicyMap) + if err != nil { + // TODO should it be an error? + return err + } + policyfileRevisionIDs = strings.Join([]string{policyfileRevisionIDs, string(appliedPolicyBytes)}, "") + } + } + + payload := map[string]interface{}{ + "started_at": bsStatus.startedAt, + "finished_at": bsStatus.finishedAt, + "policyfile_revision_ids": policyfileRevisionIDs, + "attribute_revision_id": bsStatus.attributes.revisionID, + } + err := bootstrappingSvc.ReportBootstrappingAppliedConfiguration(&payload) + if err != nil { + // TODO should it be an error? + return err + } + return nil +} diff --git a/bootstrapping/subcommands.go b/bootstrapping/subcommands.go new file mode 100644 index 0000000..69a45d0 --- /dev/null +++ b/bootstrapping/subcommands.go @@ -0,0 +1,27 @@ +package bootstrapping + +import ( + "github.com/codegangsta/cli" +) + +func SubCommands() []cli.Command { + return []cli.Command{ + { + Name: "start", + Usage: "Starts a bootstrapping routine to check and execute required activities", + Action: start, + Flags: []cli.Flag{ + cli.Int64Flag{ + Name: "time, t", + Usage: "bootstrapping time interval (seconds)", + Value: DefaultTimingInterval, + }, + }, + }, + { + Name: "stop", + Usage: "Stops the running bootstrapping process", + Action: stop, + }, + } +} diff --git a/cmd/bootstrapping_cmd.go b/cmd/bootstrapping_cmd.go new file mode 100644 index 0000000..9394b3f --- /dev/null +++ b/cmd/bootstrapping_cmd.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "github.com/codegangsta/cli" + "github.com/ingrammicro/concerto/api/blueprint" + "github.com/ingrammicro/concerto/utils" + "github.com/ingrammicro/concerto/utils/format" +) + +// WireUpBootstrapping prepares common resources to send request to API +func WireUpBootstrapping(c *cli.Context) (ds *blueprint.BootstrappingService, f format.Formatter) { + + f = format.GetFormatter() + + config, err := utils.GetConcertoConfig() + if err != nil { + f.PrintFatal("Couldn't wire up config", err) + } + hcs, err := utils.NewHTTPConcertoService(config) + if err != nil { + f.PrintFatal("Couldn't wire up concerto service", err) + } + ds, err = blueprint.NewBootstrappingService(hcs) + if err != nil { + f.PrintFatal("Couldn't wire up serverPlan service", err) + } + + return ds, f +} \ No newline at end of file diff --git a/main.go b/main.go index cd170f5..fb8383e 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + //"context" "fmt" "os" "sort" @@ -11,6 +12,7 @@ import ( "github.com/ingrammicro/concerto/blueprint/scripts" "github.com/ingrammicro/concerto/blueprint/services" "github.com/ingrammicro/concerto/blueprint/templates" + "github.com/ingrammicro/concerto/bootstrapping" "github.com/ingrammicro/concerto/brownfield" cl_prov "github.com/ingrammicro/concerto/cloud/cloud_providers" "github.com/ingrammicro/concerto/cloud/generic_images" @@ -68,6 +70,13 @@ var ServerCommands = []cli.Command{ cmdpolling.SubCommands(), ), }, + { + Name: "bootstrap", + Usage: "Manages bootstrapping commands", + Subcommands: append( + bootstrapping.SubCommands(), + ), + }, } var BlueprintCommands = []cli.Command{ diff --git a/utils/utils.go b/utils/utils.go index 8b8b2a6..ece700e 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -13,6 +13,8 @@ import ( "github.com/codegangsta/cli" + "os/exec" + log "github.com/Sirupsen/logrus" ) @@ -60,6 +62,21 @@ func Unzip(archive, target string) error { return nil } +// TODO using cmd := exec.CommandContext(ctx,... +func Untar(source, target string) error { + + if err := os.MkdirAll(target, 0600); err != nil { + return err + } + + cmd := exec.Command("tar", "-xzf", source, "-C", target) + if err := cmd.Run(); err != nil { + return err + } + + return nil +} + func ScrapeErrorMessage(message string, regExpression string) string { re, err := regexp.Compile(regExpression) @@ -218,3 +235,28 @@ func Subset(s1, s2 []string) bool { } return true } + +func RemoveFileInfo(fileInfo os.FileInfo, fileInfoName string) error { + if fileInfo.IsDir() { + d, err := os.Open(fileInfoName) + if err != nil { + return err + } + defer d.Close() + names, err := d.Readdirnames(-1) + if err != nil { + return err + } + for _, name := range names { + err = os.RemoveAll(filepath.Join(fileInfoName, name)) + if err != nil { + return err + } + } + } + + if err := os.Remove(fileInfoName); err != nil { + return err + } + return nil +} diff --git a/utils/webservice.go b/utils/webservice.go index c356184..a30cab8 100644 --- a/utils/webservice.go +++ b/utils/webservice.go @@ -20,7 +20,7 @@ type ConcertoService interface { Put(path string, payload *map[string]interface{}) ([]byte, int, error) Delete(path string) ([]byte, int, error) Get(path string) ([]byte, int, error) - GetFile(path string, directoryPath string) (string, int, error) + GetFile(path string, directoryPath string, fileName string) (string, int, error) } // HTTPConcertoservice web service manager. @@ -198,7 +198,7 @@ func (hcs *HTTPConcertoservice) Get(path string) ([]byte, int, error) { } // GetFile sends GET request to Concerto API and receives a file -func (hcs *HTTPConcertoservice) GetFile(path string, directoryPath string) (string, int, error) { +func (hcs *HTTPConcertoservice) GetFile(path string, directoryPath string, fileName string) (string, int, error) { url, _, err := hcs.prepareCall(path, nil) if err != nil { @@ -214,14 +214,19 @@ func (hcs *HTTPConcertoservice) GetFile(path string, directoryPath string) (stri defer response.Body.Close() log.Debugf("Status code:%d message:%s", response.StatusCode, response.Status) - r, err := regexp.Compile("filename=\\\"([^\\\"]*){1}\\\"") - if err != nil { - return "", response.StatusCode, err - } + realFileName := "" + if directoryPath != "" && fileName == "" { + r, err := regexp.Compile("filename=\\\"([^\\\"]*){1}\\\"") + if err != nil { + return "", response.StatusCode, err + } - // TODO check errors - fileName := r.FindStringSubmatch(response.Header.Get("Content-Disposition"))[1] - realFileName := fmt.Sprintf("%s/%s", directoryPath, fileName) + // TODO check errors + fileName = r.FindStringSubmatch(response.Header.Get("Content-Disposition"))[1] + realFileName = fmt.Sprintf("%s/%s", directoryPath, fileName) + } else { + realFileName = fileName + } output, err := os.Create(realFileName) if err != nil { diff --git a/utils/webservice_mock.go b/utils/webservice_mock.go index a70f810..aa9adeb 100644 --- a/utils/webservice_mock.go +++ b/utils/webservice_mock.go @@ -34,7 +34,7 @@ func (m *MockConcertoService) Get(path string) ([]byte, int, error) { } // GetFile sends GET request to Concerto API and receives a file -func (m *MockConcertoService) GetFile(path string, directoryPath string) (string, int, error) { +func (m *MockConcertoService) GetFile(path string, directoryPath string, fileName string) (string, int, error) { args := m.Called(path, directoryPath) return args.String(0), args.Int(1), args.Error(2) } From be2f4f2690cadecb5d208533eed120c10234499d Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Mon, 4 Mar 2019 16:19:30 +0100 Subject: [PATCH 11/30] Renamed structs (issue #90) --- bootstrapping/bootstrapping.go | 151 ++++++++++++++++----------------- 1 file changed, 72 insertions(+), 79 deletions(-) diff --git a/bootstrapping/bootstrapping.go b/bootstrapping/bootstrapping.go index 2128403..441b787 100644 --- a/bootstrapping/bootstrapping.go +++ b/bootstrapping/bootstrapping.go @@ -35,24 +35,24 @@ const ( DefaultThresholdTime = 10 ) -type bootstrappingStatus struct { - startedAt string - finishedAt string - policiesStatus []policyStatus - attributes attributesStatus +type bootstrappingProcess struct { + startedAt string + finishedAt string + policyFiles []policyFile + attributes attributes } -type attributesStatus struct { +type attributes struct { revisionID string - filename string + fileName string filePath string rawData *json.RawMessage } -type policyStatus struct { +type policyFile struct { id string revisionID string name string - filename string + fileName string tarballURL string queryURL string tarballPath string @@ -133,19 +133,15 @@ func bootstrappingRoutine(ctx context.Context, c *cli.Context, timingInterval in //formatter := format.GetFormatter() bootstrappingSvc, formatter := cmd.WireUpBootstrapping(c) - commandProcessed := make(chan bool, 1) // initialization currentTicker := time.NewTicker(time.Duration(timingInterval) * time.Second) for { - go processingCommandRoutine(bootstrappingSvc, formatter, commandProcessed) + go processingCommandRoutine(bootstrappingSvc, formatter) log.Debug("Waiting...", currentTicker) select { - //case <-commandProcessed: - // isRunningCommandRoutine = false - // log.Debug("commandProcessed") case <-currentTicker.C: log.Debug("ticker") case <-ctx.Done(): @@ -157,7 +153,7 @@ func bootstrappingRoutine(ctx context.Context, c *cli.Context, timingInterval in } // Subsidiary routine for commands processing -func processingCommandRoutine(bootstrappingSvc *blueprint.BootstrappingService, formatter format.Formatter, commandProcessed chan bool) { +func processingCommandRoutine(bootstrappingSvc *blueprint.BootstrappingService, formatter format.Formatter) { log.Debug("processingCommandRoutine") // Inquire about desired configuration changes to be applied by querying the `GET /blueprint/configuration` endpoint. This will provide a JSON response with the desired configuration changes @@ -166,116 +162,114 @@ func processingCommandRoutine(bootstrappingSvc *blueprint.BootstrappingService, formatter.PrintError("Couldn't receive bootstrapping data", err) } else { if status == 200 { - bsStatus := new(bootstrappingStatus) + bsProcess := new(bootstrappingProcess) directoryPath := getProcessingFolderFilePath() // proto structures - if err := initializePrototype(directoryPath, bsConfiguration, bsStatus); err != nil { + if err := initializePrototype(directoryPath, bsConfiguration, bsProcess); err != nil { formatter.PrintError("Cannot initialize the policy files prototypes", err) } // TODO Currently as a step previous to process tarballs policies but this can be done as a part or processing, and using defer for removing files (tgz & folder!?) // For every policyFile, ensure its tarball (downloadable through their download_url) has been downloaded to the server ... - if err := downloadPolicyFiles(bootstrappingSvc, bsStatus); err != nil { + if err := downloadPolicyFiles(bootstrappingSvc, bsProcess); err != nil { formatter.PrintError("Cannot download the policy files", err) } //... and clean off any tarball that is no longer needed. - if err := cleanObsoletePolicyFiles(directoryPath, bsStatus); err != nil { + if err := cleanObsoletePolicyFiles(directoryPath, bsProcess); err != nil { formatter.PrintError("Cannot clean obsolete policy files", err) } // Store the attributes as JSON in a file with name `attrs-.json` - if err := saveAttributes(bsStatus); err != nil { + if err := saveAttributes(bsProcess); err != nil { formatter.PrintError("Cannot save policy files attributes ", err) } // Process tarballs policies - if err := processPolicyFiles(bootstrappingSvc, bsStatus); err != nil { + if err := processPolicyFiles(bootstrappingSvc, bsProcess); err != nil { formatter.PrintError("Cannot process policy files ", err) } // Inform the platform of applied changes via a `PUT /blueprint/applied_configuration` request with a JSON payload similar to - reportAppliedConfiguration(bootstrappingSvc, bsStatus) + reportAppliedConfiguration(bootstrappingSvc, bsProcess) } } - - // TODO - commandProcessed <- true } -func initializePrototype(directoryPath string, bsConfiguration *types.BootstrappingConfiguration, bsStatus *bootstrappingStatus) error { +func initializePrototype(directoryPath string, bsConfiguration *types.BootstrappingConfiguration, bsProcess *bootstrappingProcess) error { log.Debug("initializePrototype") log.Debug("Initializing bootstrapping structures") - bsStatus.startedAt = time.Now().UTC().String() // TODO UTC? + bsProcess.startedAt = time.Now().UTC().String() // Attributes - bsStatus.attributes.revisionID = bsConfiguration.AttributeRevisionID - bsStatus.attributes.filename = strings.Join([]string{"attrs-", bsStatus.attributes.revisionID, ".json"}, "") - bsStatus.attributes.filePath = strings.Join([]string{directoryPath, bsStatus.attributes.filename}, "") - bsStatus.attributes.rawData = bsConfiguration.Attributes + bsProcess.attributes.revisionID = bsConfiguration.AttributeRevisionID + bsProcess.attributes.fileName = strings.Join([]string{"attrs-", bsProcess.attributes.revisionID, ".json"}, "") + bsProcess.attributes.filePath = strings.Join([]string{directoryPath, bsProcess.attributes.fileName}, "") + bsProcess.attributes.rawData = bsConfiguration.Attributes // Policies - for _, policyFile := range bsConfiguration.PolicyFiles { - policyStatus := new(policyStatus) - policyStatus.id = policyFile.ID - policyStatus.revisionID = policyFile.RevisionID - policyStatus.name = strings.Join([]string{policyFile.ID, "-", policyFile.RevisionID}, "") - policyStatus.filename = strings.Join([]string{policyStatus.name, ".tgz"}, "") - policyStatus.tarballURL = policyFile.DownloadURL - - url, err := url.Parse(policyStatus.tarballURL) + for _, bsConfPolicyFile := range bsConfiguration.PolicyFiles { + policyFile := new(policyFile) + policyFile.id = bsConfPolicyFile.ID + policyFile.revisionID = bsConfPolicyFile.RevisionID + + policyFile.name = strings.Join([]string{policyFile.id, "-", policyFile.revisionID}, "") + policyFile.fileName = strings.Join([]string{policyFile.name, ".tgz"}, "") + policyFile.tarballURL = bsConfPolicyFile.DownloadURL + + url, err := url.Parse(policyFile.tarballURL) if err != nil { // TODO should it be an error? return err } - policyStatus.queryURL = strings.Join([]string{url.Path[1:], url.RawQuery}, "?") + policyFile.queryURL = strings.Join([]string{url.Path[1:], url.RawQuery}, "?") - policyStatus.tarballPath = strings.Join([]string{directoryPath, policyStatus.filename}, "") - policyStatus.folderPath = strings.Join([]string{directoryPath, policyStatus.name}, "") + policyFile.tarballPath = strings.Join([]string{directoryPath, policyFile.fileName}, "") + policyFile.folderPath = strings.Join([]string{directoryPath, policyFile.name}, "") - bsStatus.policiesStatus = append(bsStatus.policiesStatus, *policyStatus) + bsProcess.policyFiles = append(bsProcess.policyFiles, *policyFile) } - log.Debug(bsStatus) + log.Debug(bsProcess) return nil } // downloadPolicyFiles For every policy file, ensure its tarball (downloadable through their download_url) has been downloaded to the server ... -func downloadPolicyFiles(bootstrappingSvc *blueprint.BootstrappingService, bsStatus *bootstrappingStatus) error { +func downloadPolicyFiles(bootstrappingSvc *blueprint.BootstrappingService, bsProcess *bootstrappingProcess) error { log.Debug("downloadPolicyFiles") - for _, policyStatus := range bsStatus.policiesStatus { - log.Debug("Downloading: ", policyStatus.tarballURL) - _, status, err := bootstrappingSvc.DownloadPolicyFile(policyStatus.queryURL, policyStatus.tarballPath) + for _, bsPolicyFile := range bsProcess.policyFiles { + log.Debug("Downloading: ", bsPolicyFile.tarballURL) + _, status, err := bootstrappingSvc.DownloadPolicyFile(bsPolicyFile.queryURL, bsPolicyFile.tarballPath) if err != nil { return err } if status == 200 { - policyStatus.downloaded = true - log.Debug("Uncompressing: ", policyStatus.tarballPath) - if err = utils.Untar(policyStatus.tarballPath, policyStatus.folderPath); err != nil { + bsPolicyFile.downloaded = true + log.Debug("Uncompressing: ", bsPolicyFile.tarballPath) + if err = utils.Untar(bsPolicyFile.tarballPath, bsPolicyFile.folderPath); err != nil { return err } - policyStatus.uncompressed = true + bsPolicyFile.uncompressed = true } else { // TODO should it be an error? - log.Error("Cannot download the policy file: ", policyStatus.filename) + log.Error("Cannot download the policy file: ", bsPolicyFile.fileName) } } return nil } // cleanObsoletePolicyFiles cleans off any tarball that is no longer needed. -func cleanObsoletePolicyFiles(directoryPath string, bsStatus *bootstrappingStatus) error { +func cleanObsoletePolicyFiles(directoryPath string, bsProcess *bootstrappingProcess) error { log.Debug("cleanObsoletePolicyFiles") // builds an array of currently processable files at this looping time - currentlyProcessableFiles := []string{bsStatus.attributes.filename} // saved attributes file name - for _, policyStatus := range bsStatus.policiesStatus { - currentlyProcessableFiles = append(currentlyProcessableFiles, policyStatus.filename) // Downloaded tgz file names - currentlyProcessableFiles = append(currentlyProcessableFiles, policyStatus.name) // Uncompressed folder names + currentlyProcessableFiles := []string{bsProcess.attributes.fileName} // saved attributes file name + for _, bsPolicyFile := range bsProcess.policyFiles { + currentlyProcessableFiles = append(currentlyProcessableFiles, bsPolicyFile.fileName) // Downloaded tgz file names + currentlyProcessableFiles = append(currentlyProcessableFiles, bsPolicyFile.name) // Uncompressed folder names } // evaluates working folder @@ -289,7 +283,7 @@ func cleanObsoletePolicyFiles(directoryPath string, bsStatus *bootstrappingStatu for _, f := range files { if !utils.Contains(currentlyProcessableFiles, f.Name()) { log.Debug("Removing: ", f.Name()) - if err := utils.RemoveFileInfo(f, strings.Join([]string{directoryPath, string(os.PathSeparator), f.Name()}, "")); err != nil { + if err := os.RemoveAll(strings.Join([]string{directoryPath, string(os.PathSeparator), f.Name()}, "")); err != nil { // TODO should it be an error? log.Warn("Cannot remove: ", f.Name(), err) } @@ -299,20 +293,19 @@ func cleanObsoletePolicyFiles(directoryPath string, bsStatus *bootstrappingStatu } // saveAttributes stores the attributes as JSON in a file with name `attrs-.json` -func saveAttributes(bsStatus *bootstrappingStatus) error { +func saveAttributes(bsProcess *bootstrappingProcess) error { log.Debug("saveAttributes") - attrs, err := json.Marshal(bsStatus.attributes.rawData) + attrs, err := json.Marshal(bsProcess.attributes.rawData) if err != nil { return err } - if err := ioutil.WriteFile(bsStatus.attributes.filePath, attrs, 0600); err != nil { + if err := ioutil.WriteFile(bsProcess.attributes.filePath, attrs, 0600); err != nil { return err } return nil } - //For every policy file, apply them doing the following: // * Extract the tarball to a temporal work directory DIR // * Run `cd DIR; chef-client -z -j path/to/attrs-.json` while sending the stderr and stdout in bunches of 10 lines to the @@ -321,16 +314,16 @@ func saveAttributes(bsStatus *bootstrappingStatus) error { // TODO On the first iteration that applies successfully all policy files (runs all `chef-client -z` commands obtaining 0 return codes) only, run the boot scripts for the server by executing the `scripts boot` sub-command (as an external process). // TODO Just a POC, an starging point. To be completed... -func processPolicyFiles(bootstrappingSvc *blueprint.BootstrappingService, bsStatus *bootstrappingStatus) error { +func processPolicyFiles(bootstrappingSvc *blueprint.BootstrappingService, bsProcess *bootstrappingProcess) error { log.Debug("processPolicyFiles") // Run `cd DIR; chef-client -z -j path/to/attrs-.json` while sending the stderr and stdout in bunches of // 10 lines to the platform via `POST /blueprint/bootstrap_logs` (this resource is a copy of POST /command_polling/bootstrap_logs used in // the command_polling command). If the command returns with a non-zero value, stop applying policyfiles and continue with the next step. - for _, policyStatus := range bsStatus.policiesStatus { - log.Warn(policyStatus.folderPath) + for _, bsPolicyFile := range bsProcess.policyFiles { + log.Warn(bsPolicyFile.folderPath) - // TODO cd ; chef-client -z -j ` + // TODO cd ; chef-client -z -j ` command := "ping -c 100 8.8.8.8" // cli command threshold flag @@ -395,29 +388,29 @@ func retry(attempts int, sleep time.Duration, fn func() error) error { // reportAppliedConfiguration Inform the platform of applied changes via a `PUT /blueprint/applied_configuration` request //The `policy file_revision_ids` field should have revision ids set only for those policy files successfully applied on the iteration, that is, // it should not have any values set for those failing and those skipped because of a previous one failing. -func reportAppliedConfiguration(bootstrappingSvc *blueprint.BootstrappingService, bsStatus *bootstrappingStatus) error { +func reportAppliedConfiguration(bootstrappingSvc *blueprint.BootstrappingService, bsProcess *bootstrappingProcess) error { log.Debug("reportAppliedConfiguration") - bsStatus.finishedAt = time.Now().UTC().String() // TODO UTC? + bsProcess.finishedAt = time.Now().UTC().String() - var policyfileRevisionIDs string - for _, policyStatus := range bsStatus.policiesStatus { - if policyStatus.executed { // only for policies successfully applied - appliedPolicyMap := map[string]string{policyStatus.id: policyStatus.revisionID} + var policyFileRevisionIDs string + for _, bsPolicyFile := range bsProcess.policyFiles { + if bsPolicyFile.executed { // only for policies successfully applied + appliedPolicyMap := map[string]string{bsPolicyFile.id: bsPolicyFile.revisionID} appliedPolicyBytes, err := json.Marshal(appliedPolicyMap) if err != nil { // TODO should it be an error? return err } - policyfileRevisionIDs = strings.Join([]string{policyfileRevisionIDs, string(appliedPolicyBytes)}, "") + policyFileRevisionIDs = strings.Join([]string{policyFileRevisionIDs, string(appliedPolicyBytes)}, "") } } payload := map[string]interface{}{ - "started_at": bsStatus.startedAt, - "finished_at": bsStatus.finishedAt, - "policyfile_revision_ids": policyfileRevisionIDs, - "attribute_revision_id": bsStatus.attributes.revisionID, + "started_at": bsProcess.startedAt, + "finished_at": bsProcess.finishedAt, + "policyfile_revision_ids": policyFileRevisionIDs, + "attribute_revision_id": bsProcess.attributes.revisionID, } err := bootstrappingSvc.ReportBootstrappingAppliedConfiguration(&payload) if err != nil { From d87d56ee41c453f9232d729c110aec973c9d933d Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Mon, 4 Mar 2019 17:06:28 +0100 Subject: [PATCH 12/30] Refactorized mechanism to download file (issue #90) --- api/blueprint/bootstrapping_api.go | 2 +- utils/webservice.go | 19 +++---------------- utils/webservice_mock.go | 4 ++-- 3 files changed, 6 insertions(+), 19 deletions(-) diff --git a/api/blueprint/bootstrapping_api.go b/api/blueprint/bootstrapping_api.go index fe45013..244776a 100644 --- a/api/blueprint/bootstrapping_api.go +++ b/api/blueprint/bootstrapping_api.go @@ -84,7 +84,7 @@ func (bs *BootstrappingService) ReportBootstrappingLog(BootstrappingContinuousRe func (bs *BootstrappingService) DownloadPolicyFile(url string, filePath string) (realFileName string, status int, err error) { log.Debug("DownloadPolicyFile") - realFileName, status, err = bs.concertoService.GetFile(url, "", filePath) + realFileName, status, err = bs.concertoService.GetFile(url, filePath) if err != nil { return realFileName, status, err } diff --git a/utils/webservice.go b/utils/webservice.go index a30cab8..6e2952d 100644 --- a/utils/webservice.go +++ b/utils/webservice.go @@ -8,7 +8,6 @@ import ( "io/ioutil" "net/http" "os" - "regexp" "strings" log "github.com/Sirupsen/logrus" @@ -20,7 +19,7 @@ type ConcertoService interface { Put(path string, payload *map[string]interface{}) ([]byte, int, error) Delete(path string) ([]byte, int, error) Get(path string) ([]byte, int, error) - GetFile(path string, directoryPath string, fileName string) (string, int, error) + GetFile(path string, filePath string) (string, int, error) } // HTTPConcertoservice web service manager. @@ -198,7 +197,7 @@ func (hcs *HTTPConcertoservice) Get(path string) ([]byte, int, error) { } // GetFile sends GET request to Concerto API and receives a file -func (hcs *HTTPConcertoservice) GetFile(path string, directoryPath string, fileName string) (string, int, error) { +func (hcs *HTTPConcertoservice) GetFile(path string, filePath string) (string, int, error) { url, _, err := hcs.prepareCall(path, nil) if err != nil { @@ -214,19 +213,7 @@ func (hcs *HTTPConcertoservice) GetFile(path string, directoryPath string, fileN defer response.Body.Close() log.Debugf("Status code:%d message:%s", response.StatusCode, response.Status) - realFileName := "" - if directoryPath != "" && fileName == "" { - r, err := regexp.Compile("filename=\\\"([^\\\"]*){1}\\\"") - if err != nil { - return "", response.StatusCode, err - } - - // TODO check errors - fileName = r.FindStringSubmatch(response.Header.Get("Content-Disposition"))[1] - realFileName = fmt.Sprintf("%s/%s", directoryPath, fileName) - } else { - realFileName = fileName - } + realFileName := filePath output, err := os.Create(realFileName) if err != nil { diff --git a/utils/webservice_mock.go b/utils/webservice_mock.go index aa9adeb..75dd0bc 100644 --- a/utils/webservice_mock.go +++ b/utils/webservice_mock.go @@ -34,7 +34,7 @@ func (m *MockConcertoService) Get(path string) ([]byte, int, error) { } // GetFile sends GET request to Concerto API and receives a file -func (m *MockConcertoService) GetFile(path string, directoryPath string, fileName string) (string, int, error) { - args := m.Called(path, directoryPath) +func (m *MockConcertoService) GetFile(path string, filePath string) (string, int, error) { + args := m.Called(path, filePath) return args.String(0), args.Int(1), args.Error(2) } From 87af58e46b6d309dca15687c0bac9505a4446fc1 Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Mon, 4 Mar 2019 17:37:28 +0100 Subject: [PATCH 13/30] Changed strategy for cleaning (issue #90) --- bootstrapping/bootstrapping.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/bootstrapping/bootstrapping.go b/bootstrapping/bootstrapping.go index 441b787..04c8008 100644 --- a/bootstrapping/bootstrapping.go +++ b/bootstrapping/bootstrapping.go @@ -265,6 +265,13 @@ func downloadPolicyFiles(bootstrappingSvc *blueprint.BootstrappingService, bsPro func cleanObsoletePolicyFiles(directoryPath string, bsProcess *bootstrappingProcess) error { log.Debug("cleanObsoletePolicyFiles") + // evaluates working folder + deletableFiles, err := ioutil.ReadDir(directoryPath) + if err != nil { + // TODO should it be an error? + log.Warn("Cannot read directory: ", directoryPath, err) + } + // builds an array of currently processable files at this looping time currentlyProcessableFiles := []string{bsProcess.attributes.fileName} // saved attributes file name for _, bsPolicyFile := range bsProcess.policyFiles { @@ -272,15 +279,7 @@ func cleanObsoletePolicyFiles(directoryPath string, bsProcess *bootstrappingProc currentlyProcessableFiles = append(currentlyProcessableFiles, bsPolicyFile.name) // Uncompressed folder names } - // evaluates working folder - files, err := ioutil.ReadDir(directoryPath) - if err != nil { - // TODO should it be an error? - log.Warn("Cannot read directory: ", directoryPath, err) - } - - // removes files not regarding to any of current policy files - for _, f := range files { + for _, f := range deletableFiles { if !utils.Contains(currentlyProcessableFiles, f.Name()) { log.Debug("Removing: ", f.Name()) if err := os.RemoveAll(strings.Join([]string{directoryPath, string(os.PathSeparator), f.Name()}, "")); err != nil { @@ -289,6 +288,7 @@ func cleanObsoletePolicyFiles(directoryPath string, bsProcess *bootstrappingProc } } } + return nil // TODO should it be managed as error? } From d25d8e698852b049e517413c669982522e144c53 Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Tue, 5 Mar 2019 14:49:22 +0100 Subject: [PATCH 14/30] General review and refactoring task (issue #90) - Refactored - Implemented lines threshold for reporting while running continuos command - Most errors in looping are only traced in order to avoid break the task - Added chef commands - Implemented concurrency control --- api/blueprint/bootstrapping_api.go | 2 +- bootstrapping/bootstrapping.go | 204 +++++++++++++---------------- bootstrapping/subcommands.go | 7 +- cmdpolling/continuousreport.go | 19 +-- utils/exec.go | 30 ++++- 5 files changed, 125 insertions(+), 137 deletions(-) diff --git a/api/blueprint/bootstrapping_api.go b/api/blueprint/bootstrapping_api.go index 244776a..2b9f859 100644 --- a/api/blueprint/bootstrapping_api.go +++ b/api/blueprint/bootstrapping_api.go @@ -80,7 +80,7 @@ func (bs *BootstrappingService) ReportBootstrappingLog(BootstrappingContinuousRe } -// +// DownloadPolicyFile gets a file from given url saving file into given file path func (bs *BootstrappingService) DownloadPolicyFile(url string, filePath string) (realFileName string, status int, err error) { log.Debug("DownloadPolicyFile") diff --git a/bootstrapping/bootstrapping.go b/bootstrapping/bootstrapping.go index 04c8008..6975c35 100644 --- a/bootstrapping/bootstrapping.go +++ b/bootstrapping/bootstrapping.go @@ -11,6 +11,8 @@ import ( "syscall" "time" "math/rand" + "fmt" + "runtime" log "github.com/Sirupsen/logrus" "github.com/codegangsta/cli" @@ -19,27 +21,24 @@ import ( "github.com/ingrammicro/concerto/cmd" "github.com/ingrammicro/concerto/utils" "github.com/ingrammicro/concerto/utils/format" - "fmt" ) const ( - // DefaultTimingInterval Default period for looping + //DefaultTimingInterval Default period for looping DefaultTimingInterval = 600 // 600 seconds = 10 minutes DefaultRandomMaxThreshold = 6 // minutes - - // ProcessIDFile - ProcessIDFile = "imco-bootstrapping.pid" - - RetriesNumber = 5 - RetriesFactor = 3 - DefaultThresholdTime = 10 + DefaultThresholdLines = 10 + ProcessIDFile = "imco-bootstrapping.pid" + RetriesNumber = 5 ) type bootstrappingProcess struct { - startedAt string - finishedAt string - policyFiles []policyFile - attributes attributes + startedAt string + finishedAt string + policyFiles []policyFile + attributes attributes + thresholdLines int + directoryPath string } type attributes struct { revisionID string @@ -102,14 +101,20 @@ func start(c *cli.Context) error { // Adds a random value to the given timing interval! // Sleep for a configured amount of time plus a random amount of time (10 minutes plus 0 to 5 minutes, for instance) timingInterval = timingInterval + int64(rand.New(rand.NewSource(time.Now().UnixNano())).Intn(DefaultRandomMaxThreshold)*60) - log.Debug("time interval:", timingInterval) + log.Debug("time interval: ", timingInterval) + + thresholdLines := c.Int("lines") + if !(thresholdLines > 0) { + thresholdLines = DefaultThresholdLines + } + log.Debug("routine lines threshold: ", thresholdLines) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go handleSysSignals(cancel) - bootstrappingRoutine(ctx, c, timingInterval) + bootstrappingRoutine(ctx, c, timingInterval, thresholdLines) return nil } @@ -128,20 +133,27 @@ func stop(c *cli.Context) error { } // Main bootstrapping background routine -func bootstrappingRoutine(ctx context.Context, c *cli.Context, timingInterval int64) { +func bootstrappingRoutine(ctx context.Context, c *cli.Context, timingInterval int64, thresholdLines int) { log.Debug("bootstrappingRoutine") - //formatter := format.GetFormatter() bootstrappingSvc, formatter := cmd.WireUpBootstrapping(c) - - // initialization + commandProcessed := make(chan bool, 1) + isRunningCommandRoutine := false currentTicker := time.NewTicker(time.Duration(timingInterval) * time.Second) for { - go processingCommandRoutine(bootstrappingSvc, formatter) + if !isRunningCommandRoutine { + isRunningCommandRoutine = true + go processingCommandRoutine(bootstrappingSvc, formatter, thresholdLines, commandProcessed) + } - log.Debug("Waiting...", currentTicker) + log.Debug("waiting...", currentTicker) select { + case <-commandProcessed: + isRunningCommandRoutine = false + currentTicker.Stop() + currentTicker = time.NewTicker(time.Duration(timingInterval) * time.Second) + log.Debug("command processed") case <-currentTicker.C: log.Debug("ticker") case <-ctx.Done(): @@ -153,61 +165,54 @@ func bootstrappingRoutine(ctx context.Context, c *cli.Context, timingInterval in } // Subsidiary routine for commands processing -func processingCommandRoutine(bootstrappingSvc *blueprint.BootstrappingService, formatter format.Formatter) { +func processingCommandRoutine(bootstrappingSvc *blueprint.BootstrappingService, formatter format.Formatter, thresholdLines int, commandProcessed chan bool) { log.Debug("processingCommandRoutine") // Inquire about desired configuration changes to be applied by querying the `GET /blueprint/configuration` endpoint. This will provide a JSON response with the desired configuration changes bsConfiguration, status, err := bootstrappingSvc.GetBootstrappingConfiguration() if err != nil { - formatter.PrintError("Couldn't receive bootstrapping data", err) + formatter.PrintError("couldn't receive bootstrapping data", err) } else { if status == 200 { bsProcess := new(bootstrappingProcess) - directoryPath := getProcessingFolderFilePath() + // Starting time + bsProcess.startedAt = time.Now().UTC().String() + bsProcess.thresholdLines = thresholdLines + bsProcess.directoryPath = getProcessingFolderFilePath() // proto structures - if err := initializePrototype(directoryPath, bsConfiguration, bsProcess); err != nil { - formatter.PrintError("Cannot initialize the policy files prototypes", err) - } + initializePrototype(bsConfiguration, bsProcess) - // TODO Currently as a step previous to process tarballs policies but this can be done as a part or processing, and using defer for removing files (tgz & folder!?) // For every policyFile, ensure its tarball (downloadable through their download_url) has been downloaded to the server ... - if err := downloadPolicyFiles(bootstrappingSvc, bsProcess); err != nil { - formatter.PrintError("Cannot download the policy files", err) - } + downloadPolicyFiles(bootstrappingSvc, bsProcess) //... and clean off any tarball that is no longer needed. - if err := cleanObsoletePolicyFiles(directoryPath, bsProcess); err != nil { - formatter.PrintError("Cannot clean obsolete policy files", err) - } + cleanObsoletePolicyFiles(bsProcess) // Store the attributes as JSON in a file with name `attrs-.json` - if err := saveAttributes(bsProcess); err != nil { - formatter.PrintError("Cannot save policy files attributes ", err) - } + saveAttributes(bsProcess) // Process tarballs policies - if err := processPolicyFiles(bootstrappingSvc, bsProcess); err != nil { - formatter.PrintError("Cannot process policy files ", err) - } + processPolicyFiles(bootstrappingSvc, bsProcess) + + // Finishing time + bsProcess.finishedAt = time.Now().UTC().String() // Inform the platform of applied changes via a `PUT /blueprint/applied_configuration` request with a JSON payload similar to + log.Debug("reporting applied policy files") reportAppliedConfiguration(bootstrappingSvc, bsProcess) } } + commandProcessed <- true } -func initializePrototype(directoryPath string, bsConfiguration *types.BootstrappingConfiguration, bsProcess *bootstrappingProcess) error { +func initializePrototype(bsConfiguration *types.BootstrappingConfiguration, bsProcess *bootstrappingProcess) { log.Debug("initializePrototype") - log.Debug("Initializing bootstrapping structures") - - bsProcess.startedAt = time.Now().UTC().String() - // Attributes bsProcess.attributes.revisionID = bsConfiguration.AttributeRevisionID bsProcess.attributes.fileName = strings.Join([]string{"attrs-", bsProcess.attributes.revisionID, ".json"}, "") - bsProcess.attributes.filePath = strings.Join([]string{directoryPath, bsProcess.attributes.fileName}, "") + bsProcess.attributes.filePath = strings.Join([]string{bsProcess.directoryPath, bsProcess.attributes.fileName}, "") bsProcess.attributes.rawData = bsConfiguration.Attributes // Policies @@ -220,56 +225,54 @@ func initializePrototype(directoryPath string, bsConfiguration *types.Bootstrapp policyFile.fileName = strings.Join([]string{policyFile.name, ".tgz"}, "") policyFile.tarballURL = bsConfPolicyFile.DownloadURL - url, err := url.Parse(policyFile.tarballURL) - if err != nil { - // TODO should it be an error? - return err + if policyFile.tarballURL != "" { + url, err := url.Parse(policyFile.tarballURL) + if err != nil { + log.Errorf("cannot parse the tarball policy file url: %s [%s]", policyFile.tarballURL, err) + } else { + policyFile.queryURL = strings.Join([]string{url.Path[1:], url.RawQuery}, "?") + } } - policyFile.queryURL = strings.Join([]string{url.Path[1:], url.RawQuery}, "?") - policyFile.tarballPath = strings.Join([]string{directoryPath, policyFile.fileName}, "") - policyFile.folderPath = strings.Join([]string{directoryPath, policyFile.name}, "") + policyFile.tarballPath = strings.Join([]string{bsProcess.directoryPath, policyFile.fileName}, "") + policyFile.folderPath = strings.Join([]string{bsProcess.directoryPath, policyFile.name}, "") bsProcess.policyFiles = append(bsProcess.policyFiles, *policyFile) } log.Debug(bsProcess) - return nil } // downloadPolicyFiles For every policy file, ensure its tarball (downloadable through their download_url) has been downloaded to the server ... -func downloadPolicyFiles(bootstrappingSvc *blueprint.BootstrappingService, bsProcess *bootstrappingProcess) error { +func downloadPolicyFiles(bootstrappingSvc *blueprint.BootstrappingService, bsProcess *bootstrappingProcess) { log.Debug("downloadPolicyFiles") for _, bsPolicyFile := range bsProcess.policyFiles { - log.Debug("Downloading: ", bsPolicyFile.tarballURL) + log.Debug("downloading: ", bsPolicyFile.tarballURL) _, status, err := bootstrappingSvc.DownloadPolicyFile(bsPolicyFile.queryURL, bsPolicyFile.tarballPath) if err != nil { - return err + log.Errorf("cannot download the tarball policy file: %s [%s]", bsPolicyFile.tarballURL, err) } if status == 200 { bsPolicyFile.downloaded = true - log.Debug("Uncompressing: ", bsPolicyFile.tarballPath) + log.Debug("decompressing: ", bsPolicyFile.tarballPath) if err = utils.Untar(bsPolicyFile.tarballPath, bsPolicyFile.folderPath); err != nil { - return err + log.Errorf("cannot decompress the tarball policy file: %s [%s]", bsPolicyFile.tarballPath, err) } bsPolicyFile.uncompressed = true } else { - // TODO should it be an error? - log.Error("Cannot download the policy file: ", bsPolicyFile.fileName) + log.Errorf("cannot download the policy file: %v", bsPolicyFile.fileName) } } - return nil } // cleanObsoletePolicyFiles cleans off any tarball that is no longer needed. -func cleanObsoletePolicyFiles(directoryPath string, bsProcess *bootstrappingProcess) error { +func cleanObsoletePolicyFiles(bsProcess *bootstrappingProcess) { log.Debug("cleanObsoletePolicyFiles") // evaluates working folder - deletableFiles, err := ioutil.ReadDir(directoryPath) + deletableFiles, err := ioutil.ReadDir(bsProcess.directoryPath) if err != nil { - // TODO should it be an error? - log.Warn("Cannot read directory: ", directoryPath, err) + log.Errorf("cannot read directory: %s [%s]", bsProcess.directoryPath, err) } // builds an array of currently processable files at this looping time @@ -279,31 +282,28 @@ func cleanObsoletePolicyFiles(directoryPath string, bsProcess *bootstrappingProc currentlyProcessableFiles = append(currentlyProcessableFiles, bsPolicyFile.name) // Uncompressed folder names } + // removes from deletableFiles array the policy files currently applied for _, f := range deletableFiles { if !utils.Contains(currentlyProcessableFiles, f.Name()) { - log.Debug("Removing: ", f.Name()) - if err := os.RemoveAll(strings.Join([]string{directoryPath, string(os.PathSeparator), f.Name()}, "")); err != nil { - // TODO should it be an error? - log.Warn("Cannot remove: ", f.Name(), err) + log.Debug("removing: ", f.Name()) + if err := os.RemoveAll(strings.Join([]string{bsProcess.directoryPath, string(os.PathSeparator), f.Name()}, "")); err != nil { + log.Errorf("cannot remove: %s [%s]", f.Name(), err) } } } - - return nil // TODO should it be managed as error? } // saveAttributes stores the attributes as JSON in a file with name `attrs-.json` -func saveAttributes(bsProcess *bootstrappingProcess) error { +func saveAttributes(bsProcess *bootstrappingProcess) { log.Debug("saveAttributes") attrs, err := json.Marshal(bsProcess.attributes.rawData) if err != nil { - return err + log.Errorf("cannot process policies attributes: %s [%s]", bsProcess.attributes.revisionID, err) } if err := ioutil.WriteFile(bsProcess.attributes.filePath, attrs, 0600); err != nil { - return err + log.Errorf("cannot save policies attributes: %s [%s]", bsProcess.attributes.revisionID, err) } - return nil } //For every policy file, apply them doing the following: @@ -314,26 +314,29 @@ func saveAttributes(bsProcess *bootstrappingProcess) error { // TODO On the first iteration that applies successfully all policy files (runs all `chef-client -z` commands obtaining 0 return codes) only, run the boot scripts for the server by executing the `scripts boot` sub-command (as an external process). // TODO Just a POC, an starging point. To be completed... -func processPolicyFiles(bootstrappingSvc *blueprint.BootstrappingService, bsProcess *bootstrappingProcess) error { +func processPolicyFiles(bootstrappingSvc *blueprint.BootstrappingService, bsProcess *bootstrappingProcess) { log.Debug("processPolicyFiles") // Run `cd DIR; chef-client -z -j path/to/attrs-.json` while sending the stderr and stdout in bunches of // 10 lines to the platform via `POST /blueprint/bootstrap_logs` (this resource is a copy of POST /command_polling/bootstrap_logs used in // the command_polling command). If the command returns with a non-zero value, stop applying policyfiles and continue with the next step. for _, bsPolicyFile := range bsProcess.policyFiles { - log.Warn(bsPolicyFile.folderPath) - - // TODO cd ; chef-client -z -j ` - command := "ping -c 100 8.8.8.8" + command := strings.Join([]string{"cd", bsPolicyFile.folderPath}, " ") + if runtime.GOOS == "windows" { + command = strings.Join([]string{command, "SET \"PATH=%PATH%;C:\\ruby\\bin;C:\\opscode\\chef\\bin;C:\\opscode\\chef\\embedded\\bin\""}, ";") + } + command = strings.Join([]string{command, strings.Join([]string{"chef-client -z -j", bsProcess.attributes.filePath}, " ")}, ";") + log.Debug(command) - // cli command threshold flag - thresholdTime := DefaultThresholdTime - log.Debug("Time threshold: ", thresholdTime) + // ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** + // TODO ** TO BE REMOVED** !!! for debugging purposes, overriding real command + command = "ping -c 100 8.8.8.8" + // ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** // Custom method for chunks processing fn := func(chunk string) error { log.Debug("sendChunks") - err := retry(RetriesNumber, time.Second, func() error { + err := utils.Retry(RetriesNumber, time.Second, func() error { log.Debug("Sending: ", chunk) commandIn := map[string]interface{}{ @@ -360,47 +363,28 @@ func processPolicyFiles(bootstrappingSvc *blueprint.BootstrappingService, bsProc return nil } - // TODO This method was implemented in some moment based on nLines, nTime, bBytes? Currently only working with thresholdTime - exitCode, err := utils.RunContinuousCmd(fn, command, thresholdTime) + exitCode, err := utils.RunContinuousCmd(fn, command, -1, bsProcess.thresholdLines) if err != nil { - log.Error("cannot process continuous report command", err) + log.Errorf("cannot process continuous report command [%s]", err) } log.Info("completed: ", exitCode) } - return nil -} - -func retry(attempts int, sleep time.Duration, fn func() error) error { - log.Debug("retry") - - if err := fn(); err != nil { - if attempts--; attempts > 0 { - log.Debug("Waiting to retry: ", sleep) - time.Sleep(sleep) - return retry(attempts, RetriesFactor*sleep, fn) - } - return err - } - return nil } // reportAppliedConfiguration Inform the platform of applied changes via a `PUT /blueprint/applied_configuration` request //The `policy file_revision_ids` field should have revision ids set only for those policy files successfully applied on the iteration, that is, // it should not have any values set for those failing and those skipped because of a previous one failing. -func reportAppliedConfiguration(bootstrappingSvc *blueprint.BootstrappingService, bsProcess *bootstrappingProcess) error { +func reportAppliedConfiguration(bootstrappingSvc *blueprint.BootstrappingService, bsProcess *bootstrappingProcess) { log.Debug("reportAppliedConfiguration") - bsProcess.finishedAt = time.Now().UTC().String() - var policyFileRevisionIDs string for _, bsPolicyFile := range bsProcess.policyFiles { if bsPolicyFile.executed { // only for policies successfully applied appliedPolicyMap := map[string]string{bsPolicyFile.id: bsPolicyFile.revisionID} appliedPolicyBytes, err := json.Marshal(appliedPolicyMap) if err != nil { - // TODO should it be an error? - return err + log.Errorf("corrupted candidates policies map [%s]", err) } policyFileRevisionIDs = strings.Join([]string{policyFileRevisionIDs, string(appliedPolicyBytes)}, "") } @@ -414,8 +398,6 @@ func reportAppliedConfiguration(bootstrappingSvc *blueprint.BootstrappingService } err := bootstrappingSvc.ReportBootstrappingAppliedConfiguration(&payload) if err != nil { - // TODO should it be an error? - return err + log.Errorf("cannot report applied configuration [%s]", err) } - return nil } diff --git a/bootstrapping/subcommands.go b/bootstrapping/subcommands.go index 69a45d0..88d5db7 100644 --- a/bootstrapping/subcommands.go +++ b/bootstrapping/subcommands.go @@ -13,9 +13,14 @@ func SubCommands() []cli.Command { Flags: []cli.Flag{ cli.Int64Flag{ Name: "time, t", - Usage: "bootstrapping time interval (seconds)", + Usage: "Bootstrapping time interval (seconds)", Value: DefaultTimingInterval, }, + cli.IntFlag{ + Name: "lines, l", + Usage: "Maximum lines threshold per response chunk", + Value: DefaultThresholdLines, + }, }, }, { diff --git a/cmdpolling/continuousreport.go b/cmdpolling/continuousreport.go index 6183388..568314f 100644 --- a/cmdpolling/continuousreport.go +++ b/cmdpolling/continuousreport.go @@ -15,7 +15,6 @@ import ( const ( RetriesNumber = 5 - RetriesFactor = 3 DefaultThresholdTime = 10 ) @@ -43,7 +42,7 @@ func cmdContinuousReportRun(c *cli.Context) error { // Custom method for chunks processing fn := func(chunk string) error { log.Debug("sendChunks") - err := retry(RetriesNumber, time.Second, func() error { + err := utils.Retry(RetriesNumber, time.Second, func() error { log.Debug("Sending: ", chunk) commandIn := map[string]interface{}{ @@ -70,7 +69,7 @@ func cmdContinuousReportRun(c *cli.Context) error { return nil } - exitCode, err := utils.RunContinuousCmd(fn, cmdArg, thresholdTime) + exitCode, err := utils.RunContinuousCmd(fn, cmdArg, thresholdTime, -1) if err != nil { formatter.PrintFatal("cannot process continuous report command", err) } @@ -79,17 +78,3 @@ func cmdContinuousReportRun(c *cli.Context) error { os.Exit(exitCode) return nil } - -func retry(attempts int, sleep time.Duration, fn func() error) error { - log.Debug("retry") - - if err := fn(); err != nil { - if attempts--; attempts > 0 { - log.Debug("Waiting to retry: ", sleep) - time.Sleep(sleep) - return retry(attempts, RetriesFactor*sleep, fn) - } - return err - } - return nil -} diff --git a/utils/exec.go b/utils/exec.go index 7268bee..f16fedc 100644 --- a/utils/exec.go +++ b/utils/exec.go @@ -19,6 +19,7 @@ import ( const ( TimeStampLayout = "2006-01-02T15:04:05.000000-07:00" TimeLayoutYYYYMMDDHHMMSS = "20060102150405" + RetriesFactor = 3 ) func extractExitCode(err error) int { @@ -234,7 +235,9 @@ func RunTracedCmd(command string) (exitCode int, stdOut string, stdErr string, s return } -func RunContinuousCmd(fn func(chunk string) error, command string, thresholdTime int) (int, error) { +// thresholdTime > 0 continuous report +// thresholdLines > 0 bootstrapping +func RunContinuousCmd(fn func(chunk string) error, command string, thresholdTime int, thresholdLines int) (int, error) { log.Debug("RunContinuousCmd") // Saves script/command in a temp file @@ -256,20 +259,19 @@ func RunContinuousCmd(fn func(chunk string) error, command string, thresholdTime } chunk := "" - nTime := 0 + nLines, nTime := 0, 0 timeStart := time.Now() scanner := bufio.NewScanner(bufio.NewReader(stdout)) for scanner.Scan() { chunk = strings.Join([]string{chunk, scanner.Text(), "\n"}, "") + nLines++ nTime = int(time.Now().Sub(timeStart).Seconds()) - if nTime >= thresholdTime { - if err := fn(chunk); err != nil { - nTime = 0 - } else { + if (thresholdTime > 0 && nTime >= thresholdTime) || (thresholdLines > 0 && nLines >= thresholdLines) { + if err := fn(chunk); err == nil { chunk = "" - nTime = 0 } + nLines, nTime = 0, 0 timeStart = time.Now() } } @@ -291,3 +293,17 @@ func RunContinuousCmd(fn func(chunk string) error, command string, thresholdTime return exitCode, nil } + +func Retry(attempts int, sleep time.Duration, fn func() error) error { + log.Debug("Retry") + + if err := fn(); err != nil { + if attempts--; attempts > 0 { + log.Debug("Waiting to retry: ", sleep) + time.Sleep(sleep) + return Retry(attempts, RetriesFactor*sleep, fn) + } + return err + } + return nil +} From 9201451be7aef5b5e55d6c016a585ac765b9ccc9 Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Tue, 5 Mar 2019 20:27:18 +0100 Subject: [PATCH 15/30] Updated routine approach (issue #90) In addition: - Implemented success case management for booting - Redefined interval time parameters --- bootstrapping/bootstrapping.go | 135 ++++++++++++++++++--------------- bootstrapping/subcommands.go | 9 ++- 2 files changed, 79 insertions(+), 65 deletions(-) diff --git a/bootstrapping/bootstrapping.go b/bootstrapping/bootstrapping.go index 6975c35..6ec0199 100644 --- a/bootstrapping/bootstrapping.go +++ b/bootstrapping/bootstrapping.go @@ -25,17 +25,17 @@ import ( const ( //DefaultTimingInterval Default period for looping - DefaultTimingInterval = 600 // 600 seconds = 10 minutes - DefaultRandomMaxThreshold = 6 // minutes - DefaultThresholdLines = 10 - ProcessIDFile = "imco-bootstrapping.pid" - RetriesNumber = 5 + DefaultTimingInterval = 600 // 600 seconds = 10 minutes + DefaultTimingSplay = 360 // seconds + DefaultThresholdLines = 10 + ProcessIDFile = "imco-bootstrapping.pid" + RetriesNumber = 5 ) type bootstrappingProcess struct { startedAt string finishedAt string - policyFiles []policyFile + policyFiles []*policyFile attributes attributes thresholdLines int directoryPath string @@ -60,9 +60,10 @@ type policyFile struct { downloaded bool uncompressed bool executed bool - logged bool } +var allPolicyFilesSuccessfullyApplied bool + // Handle signals func handleSysSignals(cancelFunc context.CancelFunc) { log.Debug("handleSysSignals") @@ -94,27 +95,12 @@ func start(c *cli.Context) error { formatter.PrintFatal("cannot create the pid file", err) } - timingInterval := c.Int64("time") - if !(timingInterval > 0) { - timingInterval = DefaultTimingInterval - } - // Adds a random value to the given timing interval! - // Sleep for a configured amount of time plus a random amount of time (10 minutes plus 0 to 5 minutes, for instance) - timingInterval = timingInterval + int64(rand.New(rand.NewSource(time.Now().UnixNano())).Intn(DefaultRandomMaxThreshold)*60) - log.Debug("time interval: ", timingInterval) - - thresholdLines := c.Int("lines") - if !(thresholdLines > 0) { - thresholdLines = DefaultThresholdLines - } - log.Debug("routine lines threshold: ", thresholdLines) - ctx, cancel := context.WithCancel(context.Background()) defer cancel() go handleSysSignals(cancel) - bootstrappingRoutine(ctx, c, timingInterval, thresholdLines) + bootstrappingRoutine(ctx, c) return nil } @@ -133,40 +119,49 @@ func stop(c *cli.Context) error { } // Main bootstrapping background routine -func bootstrappingRoutine(ctx context.Context, c *cli.Context, timingInterval int64, thresholdLines int) { +func bootstrappingRoutine(ctx context.Context, c *cli.Context) { log.Debug("bootstrappingRoutine") + timingInterval := c.Int64("interval") + if !(timingInterval > 0) { + timingInterval = DefaultTimingInterval + } + + timingSplay := c.Int64("splay") + if !(timingSplay > 0) { + timingSplay = DefaultTimingSplay + } + + thresholdLines := c.Int("lines") + if !(thresholdLines > 0) { + thresholdLines = DefaultThresholdLines + } + log.Debug("routine lines threshold: ", thresholdLines) + bootstrappingSvc, formatter := cmd.WireUpBootstrapping(c) - commandProcessed := make(chan bool, 1) - isRunningCommandRoutine := false - currentTicker := time.NewTicker(time.Duration(timingInterval) * time.Second) for { - if !isRunningCommandRoutine { - isRunningCommandRoutine = true - go processingCommandRoutine(bootstrappingSvc, formatter, thresholdLines, commandProcessed) - } + applyPolicyfiles(bootstrappingSvc, formatter, thresholdLines) - log.Debug("waiting...", currentTicker) + // Sleep for a configured amount of time plus a random amount of time (10 minutes plus 0 to 5 minutes, for instance) + ticker := time.NewTicker(time.Duration(timingInterval + int64(rand.New(rand.NewSource(time.Now().UnixNano())).Intn(int(timingSplay)))) * time.Second) select { - case <-commandProcessed: - isRunningCommandRoutine = false - currentTicker.Stop() - currentTicker = time.NewTicker(time.Duration(timingInterval) * time.Second) - log.Debug("command processed") - case <-currentTicker.C: + case <- ticker.C: log.Debug("ticker") - case <-ctx.Done(): + case <- ctx.Done(): log.Debug(ctx.Err()) log.Debug("closing bootstrapping") - return + } + ticker.Stop() + if ctx.Err() != nil { + break } } } // Subsidiary routine for commands processing -func processingCommandRoutine(bootstrappingSvc *blueprint.BootstrappingService, formatter format.Formatter, thresholdLines int, commandProcessed chan bool) { - log.Debug("processingCommandRoutine") +func applyPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, formatter format.Formatter, thresholdLines int) { + log.Debug("applyPolicyfiles") // Inquire about desired configuration changes to be applied by querying the `GET /blueprint/configuration` endpoint. This will provide a JSON response with the desired configuration changes bsConfiguration, status, err := bootstrappingSvc.GetBootstrappingConfiguration() @@ -201,9 +196,10 @@ func processingCommandRoutine(bootstrappingSvc *blueprint.BootstrappingService, // Inform the platform of applied changes via a `PUT /blueprint/applied_configuration` request with a JSON payload similar to log.Debug("reporting applied policy files") reportAppliedConfiguration(bootstrappingSvc, bsProcess) + + completeBootstrappingSequence(bsProcess) } } - commandProcessed <- true } func initializePrototype(bsConfiguration *types.BootstrappingConfiguration, bsProcess *bootstrappingProcess) { @@ -237,7 +233,7 @@ func initializePrototype(bsConfiguration *types.BootstrappingConfiguration, bsPr policyFile.tarballPath = strings.Join([]string{bsProcess.directoryPath, policyFile.fileName}, "") policyFile.folderPath = strings.Join([]string{bsProcess.directoryPath, policyFile.name}, "") - bsProcess.policyFiles = append(bsProcess.policyFiles, *policyFile) + bsProcess.policyFiles = append(bsProcess.policyFiles, policyFile) } log.Debug(bsProcess) } @@ -306,20 +302,10 @@ func saveAttributes(bsProcess *bootstrappingProcess) { } } -//For every policy file, apply them doing the following: -// * Extract the tarball to a temporal work directory DIR -// * Run `cd DIR; chef-client -z -j path/to/attrs-.json` while sending the stderr and stdout in bunches of 10 lines to the -// platform via `POST /blueprint/bootstrap_logs` (this resource is a copy of POST /command_polling/bootstrap_logs used in the command_polling command). -// If the command returns with a non-zero value, stop applying policy files and continue with the next step. - -// TODO On the first iteration that applies successfully all policy files (runs all `chef-client -z` commands obtaining 0 return codes) only, run the boot scripts for the server by executing the `scripts boot` sub-command (as an external process). -// TODO Just a POC, an starging point. To be completed... +// processPolicyFiles applies for each policy the required chef commands, reporting in bunches of N lines func processPolicyFiles(bootstrappingSvc *blueprint.BootstrappingService, bsProcess *bootstrappingProcess) { log.Debug("processPolicyFiles") - // Run `cd DIR; chef-client -z -j path/to/attrs-.json` while sending the stderr and stdout in bunches of - // 10 lines to the platform via `POST /blueprint/bootstrap_logs` (this resource is a copy of POST /command_polling/bootstrap_logs used in - // the command_polling command). If the command returns with a non-zero value, stop applying policyfiles and continue with the next step. for _, bsPolicyFile := range bsProcess.policyFiles { command := strings.Join([]string{"cd", bsPolicyFile.folderPath}, " ") if runtime.GOOS == "windows" { @@ -328,11 +314,6 @@ func processPolicyFiles(bootstrappingSvc *blueprint.BootstrappingService, bsProc command = strings.Join([]string{command, strings.Join([]string{"chef-client -z -j", bsProcess.attributes.filePath}, " ")}, ";") log.Debug(command) - // ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** - // TODO ** TO BE REMOVED** !!! for debugging purposes, overriding real command - command = "ping -c 100 8.8.8.8" - // ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** - // Custom method for chunks processing fn := func(chunk string) error { log.Debug("sendChunks") @@ -369,12 +350,16 @@ func processPolicyFiles(bootstrappingSvc *blueprint.BootstrappingService, bsProc } log.Info("completed: ", exitCode) + + bsPolicyFile.executed = exitCode == 0 // policy successfully applied + //If the command returns with a non-zero value, stop applying policyfiles and continue with the next step. + if !bsPolicyFile.executed { + break + } } } -// reportAppliedConfiguration Inform the platform of applied changes via a `PUT /blueprint/applied_configuration` request -//The `policy file_revision_ids` field should have revision ids set only for those policy files successfully applied on the iteration, that is, -// it should not have any values set for those failing and those skipped because of a previous one failing. +// reportAppliedConfiguration Inform the platform of applied changes func reportAppliedConfiguration(bootstrappingSvc *blueprint.BootstrappingService, bsProcess *bootstrappingProcess) { log.Debug("reportAppliedConfiguration") @@ -401,3 +386,27 @@ func reportAppliedConfiguration(bootstrappingSvc *blueprint.BootstrappingService log.Errorf("cannot report applied configuration [%s]", err) } } + +// completeBootstrappingSequence evaluates if the first iteration of policies was completed; If case, execute the "scripts boot" command. +func completeBootstrappingSequence(bsProcess *bootstrappingProcess) { + log.Debug("completeBootstrappingSequence") + + if !allPolicyFilesSuccessfullyApplied { + checked := true + for _, bsPolicyFile := range bsProcess.policyFiles { + if !bsPolicyFile.executed { + checked = false + break + } + } + allPolicyFilesSuccessfullyApplied = checked + + if allPolicyFilesSuccessfullyApplied { + log.Debug("run the boot scripts") + //run the boot scripts for the server by executing the scripts boot sub-command (as an external process). + if output, exit, _, _ := utils.RunCmd( strings.Join([]string{os.Args[0], "scripts", "boot"}, " ")); exit != 0 { + log.Errorf("Error executing scripts boot: (%d) %s", exit, output) + } + } + } +} diff --git a/bootstrapping/subcommands.go b/bootstrapping/subcommands.go index 88d5db7..119ab29 100644 --- a/bootstrapping/subcommands.go +++ b/bootstrapping/subcommands.go @@ -12,10 +12,15 @@ func SubCommands() []cli.Command { Action: start, Flags: []cli.Flag{ cli.Int64Flag{ - Name: "time, t", - Usage: "Bootstrapping time interval (seconds)", + Name: "interval, i", + Usage: "The frequency (in seconds) at which the bootstrapping runs", Value: DefaultTimingInterval, }, + cli.Int64Flag{ + Name: "splay, s", + Usage: "A random number between zero and splay that is added to interval (seconds)", + Value: DefaultTimingSplay, + }, cli.IntFlag{ Name: "lines, l", Usage: "Maximum lines threshold per response chunk", From 54a1d11fee4f7dcb4904caca7eb7aaf09e75acfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Ban=CC=83os=20Lo=CC=81pez?= Date: Wed, 6 Mar 2019 12:18:26 +0100 Subject: [PATCH 16/30] Some refactor of agent bootstrapping command including error processing (issue #90) --- api/blueprint/bootstrapping_api.go | 6 +- api/types/bootstrapping.go | 6 +- bootstrapping/bootstrapping.go | 377 ++++++++++++++--------------- 3 files changed, 192 insertions(+), 197 deletions(-) diff --git a/api/blueprint/bootstrapping_api.go b/api/blueprint/bootstrapping_api.go index 2b9f859..14ab9f2 100644 --- a/api/blueprint/bootstrapping_api.go +++ b/api/blueprint/bootstrapping_api.go @@ -80,9 +80,9 @@ func (bs *BootstrappingService) ReportBootstrappingLog(BootstrappingContinuousRe } -// DownloadPolicyFile gets a file from given url saving file into given file path -func (bs *BootstrappingService) DownloadPolicyFile(url string, filePath string) (realFileName string, status int, err error) { - log.Debug("DownloadPolicyFile") +// DownloadPolicyfile gets a file from given url saving file into given file path +func (bs *BootstrappingService) DownloadPolicyfile(url string, filePath string) (realFileName string, status int, err error) { + log.Debug("DownloadPolicyfile") realFileName, status, err = bs.concertoService.GetFile(url, filePath) if err != nil { diff --git a/api/types/bootstrapping.go b/api/types/bootstrapping.go index 76e9918..1fee663 100644 --- a/api/types/bootstrapping.go +++ b/api/types/bootstrapping.go @@ -5,12 +5,12 @@ import ( ) type BootstrappingConfiguration struct { - PolicyFiles []BootstrappingPolicyFile `json:"policyfiles,omitempty" header:"POLICY FILES" show:"nolist"` + Policyfiles []BootstrappingPolicyfile `json:"policyfiles,omitempty" header:"POLICY FILES" show:"nolist"` Attributes *json.RawMessage `json:"attributes,omitempty" header:"ATTRIBUTES" show:"nolist"` AttributeRevisionID string `json:"attribute_revision_id,omitempty" header:"ATTRIBUTE REVISION ID"` } -type BootstrappingPolicyFile struct { +type BootstrappingPolicyfile struct { ID string `json:"id,omitempty" header:"ID"` RevisionID string `json:"revision_id,omitempty" header:"REVISION ID"` DownloadURL string `json:"download_url,omitempty" header:"DOWNLOAD URL"` @@ -23,6 +23,6 @@ type BootstrappingContinuousReport struct { type BootstrappingAppliedConfiguration struct { StartedAt string `json:"started_at,omitempty" header:"STARTED AT"` FinishedAt string `json:"finished_at,omitempty" header:"FINISHED AT"` - PolicyFileRevisionIDs string `json:"policyfile_revision_ids,omitempty" header:"POLICY FILE REVISION IDS" show:"nolist"` + PolicyfileRevisionIDs string `json:"policyfile_revision_ids,omitempty" header:"POLICY FILE REVISION IDS" show:"nolist"` AttributeRevisionID string `json:"attribute_revision_id,omitempty" header:"ATTRIBUTE REVISION ID"` } diff --git a/bootstrapping/bootstrapping.go b/bootstrapping/bootstrapping.go index 6ec0199..7adaeb9 100644 --- a/bootstrapping/bootstrapping.go +++ b/bootstrapping/bootstrapping.go @@ -3,16 +3,17 @@ package bootstrapping import ( "context" "encoding/json" + "fmt" "io/ioutil" + "math/rand" "net/url" "os" "os/signal" + "path/filepath" + "runtime" "strings" "syscall" "time" - "math/rand" - "fmt" - "runtime" log "github.com/Sirupsen/logrus" "github.com/codegangsta/cli" @@ -33,36 +34,22 @@ const ( ) type bootstrappingProcess struct { - startedAt string - finishedAt string - policyFiles []*policyFile - attributes attributes - thresholdLines int - directoryPath string + startedAt time.Time + finishedAt time.Time + policyfiles []policyfile + attributes attributes + thresholdLines int + directoryPath string + appliedPolicyfileRevisionIDs map[string]string } type attributes struct { revisionID string - fileName string - filePath string rawData *json.RawMessage } -type policyFile struct { - id string - revisionID string - name string - fileName string - tarballURL string - queryURL string - tarballPath string - folderPath string - - downloaded bool - uncompressed bool - executed bool -} +type policyfile types.BootstrappingPolicyfile -var allPolicyFilesSuccessfullyApplied bool +var allPolicyfilesSuccessfullyApplied bool // Handle signals func handleSysSignals(cancelFunc context.CancelFunc) { @@ -74,12 +61,12 @@ func handleSysSignals(cancelFunc context.CancelFunc) { cancelFunc() } -// Returns the full path to the tmp folder joined with pid management file name +// Returns the full path to the tmp directory joined with pid management file name func getProcessIDFilePath() string { return strings.Join([]string{os.TempDir(), string(os.PathSeparator), ProcessIDFile}, "") } -// Returns the full path to the tmp folder +// Returns the full path to the tmp directory func getProcessingFolderFilePath() string { dir := strings.Join([]string{os.TempDir(), string(os.PathSeparator), "imco", string(os.PathSeparator)}, "") os.Mkdir(dir, 0777) @@ -100,28 +87,6 @@ func start(c *cli.Context) error { go handleSysSignals(cancel) - bootstrappingRoutine(ctx, c) - - return nil -} - -// Stop the bootstrapping process -func stop(c *cli.Context) error { - log.Debug("cmdStop") - - formatter := format.GetFormatter() - if err := utils.StopProcess(getProcessIDFilePath()); err != nil { - formatter.PrintFatal("cannot stop the bootstrapping process", err) - } - - log.Info("Bootstrapping routine successfully stopped") - return nil -} - -// Main bootstrapping background routine -func bootstrappingRoutine(ctx context.Context, c *cli.Context) { - log.Debug("bootstrappingRoutine") - timingInterval := c.Int64("interval") if !(timingInterval > 0) { timingInterval = DefaultTimingInterval @@ -138,17 +103,18 @@ func bootstrappingRoutine(ctx context.Context, c *cli.Context) { } log.Debug("routine lines threshold: ", thresholdLines) + r := rand.New(rand.NewSource(time.Now().UnixNano())) bootstrappingSvc, formatter := cmd.WireUpBootstrapping(c) for { applyPolicyfiles(bootstrappingSvc, formatter, thresholdLines) // Sleep for a configured amount of time plus a random amount of time (10 minutes plus 0 to 5 minutes, for instance) - ticker := time.NewTicker(time.Duration(timingInterval + int64(rand.New(rand.NewSource(time.Now().UnixNano())).Intn(int(timingSplay)))) * time.Second) + ticker := time.NewTicker(time.Duration(timingInterval+int64(r.Intn(int(timingSplay)))) * time.Second) select { - case <- ticker.C: + case <-ticker.C: log.Debug("ticker") - case <- ctx.Done(): + case <-ctx.Done(): log.Debug(ctx.Err()) log.Debug("closing bootstrapping") } @@ -157,161 +123,180 @@ func bootstrappingRoutine(ctx context.Context, c *cli.Context) { break } } + + return nil +} + +// Stop the bootstrapping process +func stop(c *cli.Context) error { + log.Debug("cmdStop") + + formatter := format.GetFormatter() + if err := utils.StopProcess(getProcessIDFilePath()); err != nil { + formatter.PrintFatal("cannot stop the bootstrapping process", err) + } + + log.Info("Bootstrapping routine successfully stopped") + return nil } // Subsidiary routine for commands processing -func applyPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, formatter format.Formatter, thresholdLines int) { +func applyPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, formatter format.Formatter, thresholdLines int) error { log.Debug("applyPolicyfiles") // Inquire about desired configuration changes to be applied by querying the `GET /blueprint/configuration` endpoint. This will provide a JSON response with the desired configuration changes bsConfiguration, status, err := bootstrappingSvc.GetBootstrappingConfiguration() + if err == nil && status != 200 { + err = fmt.Errorf("received non-ok %d response") + } if err != nil { formatter.PrintError("couldn't receive bootstrapping data", err) - } else { - if status == 200 { - bsProcess := new(bootstrappingProcess) - // Starting time - bsProcess.startedAt = time.Now().UTC().String() - bsProcess.thresholdLines = thresholdLines - bsProcess.directoryPath = getProcessingFolderFilePath() - - // proto structures - initializePrototype(bsConfiguration, bsProcess) - - // For every policyFile, ensure its tarball (downloadable through their download_url) has been downloaded to the server ... - downloadPolicyFiles(bootstrappingSvc, bsProcess) - - //... and clean off any tarball that is no longer needed. - cleanObsoletePolicyFiles(bsProcess) - - // Store the attributes as JSON in a file with name `attrs-.json` - saveAttributes(bsProcess) - - // Process tarballs policies - processPolicyFiles(bootstrappingSvc, bsProcess) - - // Finishing time - bsProcess.finishedAt = time.Now().UTC().String() + return err + } + bsProcess := &bootstrappingProcess{ + startedAt: time.Now().UTC(), + thresholdLines: thresholdLines, + directoryPath: getProcessingFolderFilePath(), + appliedPolicyfileRevisionIDs: make(map[string]string), + } - // Inform the platform of applied changes via a `PUT /blueprint/applied_configuration` request with a JSON payload similar to - log.Debug("reporting applied policy files") - reportAppliedConfiguration(bootstrappingSvc, bsProcess) + // proto structures + err = initializePrototype(bsConfiguration, bsProcess) + if err != nil { + return err + } + // For every policyfile, ensure its tarball (downloadable through their download_url) has been downloaded to the server ... + err = downloadPolicyfiles(bootstrappingSvc, bsProcess) + if err != nil { + return err + } + //... and clean off any tarball that is no longer needed. + err = cleanObsoletePolicyfiles(bsProcess) + if err != nil { + return err + } + // Store the attributes as JSON in a file with name `attrs-.json` + err = saveAttributes(bsProcess) + if err != nil { + return err + } + // Process tarballs policies + err = processPolicyfiles(bootstrappingSvc, bsProcess) + if err != nil { + return err + } + // Finishing time + bsProcess.finishedAt = time.Now().UTC() - completeBootstrappingSequence(bsProcess) - } + // Inform the platform of applied changes via a `PUT /blueprint/applied_configuration` request with a JSON payload similar to + log.Debug("reporting applied policy files") + err = reportAppliedConfiguration(bootstrappingSvc, bsProcess) + if err != nil { + return err } + return completeBootstrappingSequence(bsProcess) } -func initializePrototype(bsConfiguration *types.BootstrappingConfiguration, bsProcess *bootstrappingProcess) { +func initializePrototype(bsConfiguration *types.BootstrappingConfiguration, bsProcess *bootstrappingProcess) error { log.Debug("initializePrototype") // Attributes bsProcess.attributes.revisionID = bsConfiguration.AttributeRevisionID - bsProcess.attributes.fileName = strings.Join([]string{"attrs-", bsProcess.attributes.revisionID, ".json"}, "") - bsProcess.attributes.filePath = strings.Join([]string{bsProcess.directoryPath, bsProcess.attributes.fileName}, "") bsProcess.attributes.rawData = bsConfiguration.Attributes // Policies - for _, bsConfPolicyFile := range bsConfiguration.PolicyFiles { - policyFile := new(policyFile) - policyFile.id = bsConfPolicyFile.ID - policyFile.revisionID = bsConfPolicyFile.RevisionID - - policyFile.name = strings.Join([]string{policyFile.id, "-", policyFile.revisionID}, "") - policyFile.fileName = strings.Join([]string{policyFile.name, ".tgz"}, "") - policyFile.tarballURL = bsConfPolicyFile.DownloadURL - - if policyFile.tarballURL != "" { - url, err := url.Parse(policyFile.tarballURL) - if err != nil { - log.Errorf("cannot parse the tarball policy file url: %s [%s]", policyFile.tarballURL, err) - } else { - policyFile.queryURL = strings.Join([]string{url.Path[1:], url.RawQuery}, "?") - } - } - - policyFile.tarballPath = strings.Join([]string{bsProcess.directoryPath, policyFile.fileName}, "") - policyFile.folderPath = strings.Join([]string{bsProcess.directoryPath, policyFile.name}, "") - - bsProcess.policyFiles = append(bsProcess.policyFiles, policyFile) + for _, bsConfPolicyfile := range bsConfiguration.Policyfiles { + bsProcess.policyfiles = append(bsProcess.policyfiles, policyfile(bsConfPolicyfile)) } log.Debug(bsProcess) + return nil } -// downloadPolicyFiles For every policy file, ensure its tarball (downloadable through their download_url) has been downloaded to the server ... -func downloadPolicyFiles(bootstrappingSvc *blueprint.BootstrappingService, bsProcess *bootstrappingProcess) { - log.Debug("downloadPolicyFiles") +// downloadPolicyfiles For every policy file, ensure its tarball (downloadable through their download_url) has been downloaded to the server ... +func downloadPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, bsProcess *bootstrappingProcess) error { + log.Debug("downloadPolicyfiles") - for _, bsPolicyFile := range bsProcess.policyFiles { - log.Debug("downloading: ", bsPolicyFile.tarballURL) - _, status, err := bootstrappingSvc.DownloadPolicyFile(bsPolicyFile.queryURL, bsPolicyFile.tarballPath) + for _, bsPolicyfile := range bsProcess.policyfiles { + tarballPath := bsPolicyfile.TarballPath(bsProcess.directoryPath) + log.Debug("downloading: ", tarballPath) + queryURL, err := bsPolicyfile.QueryURL() if err != nil { - log.Errorf("cannot download the tarball policy file: %s [%s]", bsPolicyFile.tarballURL, err) + return err } - if status == 200 { - bsPolicyFile.downloaded = true - log.Debug("decompressing: ", bsPolicyFile.tarballPath) - if err = utils.Untar(bsPolicyFile.tarballPath, bsPolicyFile.folderPath); err != nil { - log.Errorf("cannot decompress the tarball policy file: %s [%s]", bsPolicyFile.tarballPath, err) - } - bsPolicyFile.uncompressed = true - } else { - log.Errorf("cannot download the policy file: %v", bsPolicyFile.fileName) + _, status, err := bootstrappingSvc.DownloadPolicyfile(queryURL, tarballPath) + if err == nil && status != 200 { + err = fmt.Errorf("obtained non-ok response when downloading policyfile %s", queryURL) + } + if err != nil { + return err + } + if err = utils.Untar(tarballPath, bsPolicyfile.Path(bsProcess.directoryPath)); err != nil { + return err } } + return nil } -// cleanObsoletePolicyFiles cleans off any tarball that is no longer needed. -func cleanObsoletePolicyFiles(bsProcess *bootstrappingProcess) { - log.Debug("cleanObsoletePolicyFiles") +// cleanObsoletePolicyfiles cleans off any tarball that is no longer needed. +func cleanObsoletePolicyfiles(bsProcess *bootstrappingProcess) error { + log.Debug("cleanObsoletePolicyfiles") - // evaluates working folder + // evaluates working directory deletableFiles, err := ioutil.ReadDir(bsProcess.directoryPath) if err != nil { - log.Errorf("cannot read directory: %s [%s]", bsProcess.directoryPath, err) + return err } - // builds an array of currently processable files at this looping time - currentlyProcessableFiles := []string{bsProcess.attributes.fileName} // saved attributes file name - for _, bsPolicyFile := range bsProcess.policyFiles { - currentlyProcessableFiles = append(currentlyProcessableFiles, bsPolicyFile.fileName) // Downloaded tgz file names - currentlyProcessableFiles = append(currentlyProcessableFiles, bsPolicyFile.name) // Uncompressed folder names + // removes from deletableFiles those files we are going to use + for _, bsPolicyfile := range bsProcess.policyfiles { + for i, file := range deletableFiles { + if file.Name() == bsPolicyfile.FileName() { + deletableFiles[i] = deletableFiles[len(deletableFiles)-1] + deletableFiles = deletableFiles[:len(deletableFiles)-1] + break + } + if file.Name() == bsPolicyfile.Name() { + deletableFiles[i] = deletableFiles[len(deletableFiles)-1] + deletableFiles = deletableFiles[:len(deletableFiles)-1] + break + } + } } // removes from deletableFiles array the policy files currently applied for _, f := range deletableFiles { - if !utils.Contains(currentlyProcessableFiles, f.Name()) { - log.Debug("removing: ", f.Name()) - if err := os.RemoveAll(strings.Join([]string{bsProcess.directoryPath, string(os.PathSeparator), f.Name()}, "")); err != nil { - log.Errorf("cannot remove: %s [%s]", f.Name(), err) - } + log.Debug("removing: ", f.Name()) + if err := os.RemoveAll(strings.Join([]string{bsProcess.directoryPath, string(os.PathSeparator), f.Name()}, "")); err != nil { + return err } } + return nil } // saveAttributes stores the attributes as JSON in a file with name `attrs-.json` -func saveAttributes(bsProcess *bootstrappingProcess) { +func saveAttributes(bsProcess *bootstrappingProcess) error { log.Debug("saveAttributes") attrs, err := json.Marshal(bsProcess.attributes.rawData) if err != nil { - log.Errorf("cannot process policies attributes: %s [%s]", bsProcess.attributes.revisionID, err) + return err } - if err := ioutil.WriteFile(bsProcess.attributes.filePath, attrs, 0600); err != nil { - log.Errorf("cannot save policies attributes: %s [%s]", bsProcess.attributes.revisionID, err) + if err := ioutil.WriteFile(bsProcess.attributes.FilePath(bsProcess.directoryPath), attrs, 0600); err != nil { + return err } + return nil } -// processPolicyFiles applies for each policy the required chef commands, reporting in bunches of N lines -func processPolicyFiles(bootstrappingSvc *blueprint.BootstrappingService, bsProcess *bootstrappingProcess) { - log.Debug("processPolicyFiles") +// processPolicyfiles applies for each policy the required chef commands, reporting in bunches of N lines +func processPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, bsProcess *bootstrappingProcess) error { + log.Debug("processPolicyfiles") - for _, bsPolicyFile := range bsProcess.policyFiles { - command := strings.Join([]string{"cd", bsPolicyFile.folderPath}, " ") + for _, bsPolicyfile := range bsProcess.policyfiles { + command := strings.Join([]string{"cd", bsPolicyfile.Path(bsProcess.directoryPath)}, " ") if runtime.GOOS == "windows" { command = strings.Join([]string{command, "SET \"PATH=%PATH%;C:\\ruby\\bin;C:\\opscode\\chef\\bin;C:\\opscode\\chef\\embedded\\bin\""}, ";") } - command = strings.Join([]string{command, strings.Join([]string{"chef-client -z -j", bsProcess.attributes.filePath}, " ")}, ";") + command = strings.Join([]string{command, strings.Join([]string{"chef-client -z -j", bsProcess.attributes.FilePath(bsProcess.directoryPath)}, " ")}, ";") log.Debug(command) // Custom method for chunks processing @@ -345,68 +330,78 @@ func processPolicyFiles(bootstrappingSvc *blueprint.BootstrappingService, bsProc } exitCode, err := utils.RunContinuousCmd(fn, command, -1, bsProcess.thresholdLines) + if err == nil && exitCode != 0 { + err = fmt.Errorf("policyfile application exited with %d code", exitCode) + } if err != nil { - log.Errorf("cannot process continuous report command [%s]", err) + return err } log.Info("completed: ", exitCode) - - bsPolicyFile.executed = exitCode == 0 // policy successfully applied - //If the command returns with a non-zero value, stop applying policyfiles and continue with the next step. - if !bsPolicyFile.executed { - break - } + bsProcess.appliedPolicyfileRevisionIDs[bsPolicyfile.ID] = bsPolicyfile.RevisionID } + return nil } // reportAppliedConfiguration Inform the platform of applied changes -func reportAppliedConfiguration(bootstrappingSvc *blueprint.BootstrappingService, bsProcess *bootstrappingProcess) { +func reportAppliedConfiguration(bootstrappingSvc *blueprint.BootstrappingService, bsProcess *bootstrappingProcess) error { log.Debug("reportAppliedConfiguration") - var policyFileRevisionIDs string - for _, bsPolicyFile := range bsProcess.policyFiles { - if bsPolicyFile.executed { // only for policies successfully applied - appliedPolicyMap := map[string]string{bsPolicyFile.id: bsPolicyFile.revisionID} - appliedPolicyBytes, err := json.Marshal(appliedPolicyMap) - if err != nil { - log.Errorf("corrupted candidates policies map [%s]", err) - } - policyFileRevisionIDs = strings.Join([]string{policyFileRevisionIDs, string(appliedPolicyBytes)}, "") - } - } - payload := map[string]interface{}{ "started_at": bsProcess.startedAt, "finished_at": bsProcess.finishedAt, - "policyfile_revision_ids": policyFileRevisionIDs, + "policyfile_revision_ids": bsProcess.appliedPolicyfileRevisionIDs, "attribute_revision_id": bsProcess.attributes.revisionID, } - err := bootstrappingSvc.ReportBootstrappingAppliedConfiguration(&payload) - if err != nil { - log.Errorf("cannot report applied configuration [%s]", err) - } + return bootstrappingSvc.ReportBootstrappingAppliedConfiguration(&payload) } // completeBootstrappingSequence evaluates if the first iteration of policies was completed; If case, execute the "scripts boot" command. -func completeBootstrappingSequence(bsProcess *bootstrappingProcess) { +func completeBootstrappingSequence(bsProcess *bootstrappingProcess) error { log.Debug("completeBootstrappingSequence") - if !allPolicyFilesSuccessfullyApplied { - checked := true - for _, bsPolicyFile := range bsProcess.policyFiles { - if !bsPolicyFile.executed { - checked = false - break - } + if !allPolicyfilesSuccessfullyApplied { + log.Debug("run the boot scripts") + //run the boot scripts for the server by executing the scripts boot sub-command (as an external process). + if output, exit, _, _ := utils.RunCmd(strings.Join([]string{os.Args[0], "scripts", "boot"}, " ")); exit != 0 { + return fmt.Errorf("boot scripts run failed with exit code %d and following output: %s", exit, output) } - allPolicyFilesSuccessfullyApplied = checked + allPolicyfilesSuccessfullyApplied = true + } + return nil +} - if allPolicyFilesSuccessfullyApplied { - log.Debug("run the boot scripts") - //run the boot scripts for the server by executing the scripts boot sub-command (as an external process). - if output, exit, _, _ := utils.RunCmd( strings.Join([]string{os.Args[0], "scripts", "boot"}, " ")); exit != 0 { - log.Errorf("Error executing scripts boot: (%d) %s", exit, output) - } - } +func (pf policyfile) Name() string { + return strings.Join([]string{pf.ID, "-", pf.RevisionID}, "") +} + +func (pf *policyfile) FileName() string { + return strings.Join([]string{pf.Name(), "tgz"}, ".") +} + +func (pf *policyfile) QueryURL() (string, error) { + if pf.DownloadURL == "" { + return "", fmt.Errorf("obtaining URL query: empty download URL") } + url, err := url.Parse(pf.DownloadURL) + if err != nil { + return "", fmt.Errorf("parsing URL to extract query: %v", err) + } + return strings.Join([]string{url.Path[1:], url.RawQuery}, "?"), nil +} + +func (pf *policyfile) TarballPath(dir string) string { + return filepath.Join(dir, pf.FileName()) +} + +func (pf *policyfile) Path(dir string) string { + return filepath.Join(dir, pf.Name()) +} + +func (a *attributes) FileName() string { + return fmt.Sprintf("attrs-%s.json", a.revisionID) +} + +func (a *attributes) FilePath(dir string) string { + return filepath.Join(dir, a.FileName()) } From 1bf8859b6e19dff3a5343845161141ae299bb100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Ban=CC=83os=20Lo=CC=81pez?= Date: Wed, 6 Mar 2019 13:19:15 +0100 Subject: [PATCH 17/30] Ensure a single instance of the bootstrapping command works at a time (issue #90) --- Gopkg.lock | 8 +++++++- Gopkg.toml | 4 ++++ bootstrapping/bootstrapping.go | 34 ++++++++++++++++++++++++++++------ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/Gopkg.lock b/Gopkg.lock index e7ea940..5503c24 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -17,6 +17,12 @@ revision = "4b6ea7319e214d98c938f12692336f7ca9348d6b" version = "v0.10.0" +[[projects]] + branch = "master" + name = "github.com/allan-simon/go-singleinstance" + packages = ["."] + revision = "79edcfdc2dfc93da913f46ae8d9f8a9602250431" + [[projects]] name = "github.com/asaskevich/govalidator" packages = ["."] @@ -91,6 +97,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "92b7325f6a94cda806d124a617bcc6703c943d68c51e2be3182ef4ed42dc35a7" + inputs-digest = "110d97437b0209f38ce5b41683f4bdb8842f608d32b27d184ddc13edb6bfd8c9" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index 62d3944..99977a0 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -55,3 +55,7 @@ [[constraint]] name = "github.com/pmezard/go-difflib" revision = "792786c7400a136282c1664665ae0a8db921c6c2" + +[[constraint]] + branch = "master" + name = "github.com/allan-simon/go-singleinstance" diff --git a/bootstrapping/bootstrapping.go b/bootstrapping/bootstrapping.go index 7adaeb9..bb85bff 100644 --- a/bootstrapping/bootstrapping.go +++ b/bootstrapping/bootstrapping.go @@ -16,6 +16,7 @@ import ( "time" log "github.com/Sirupsen/logrus" + singleinstance "github.com/allan-simon/go-singleinstance" "github.com/codegangsta/cli" "github.com/ingrammicro/concerto/api/blueprint" "github.com/ingrammicro/concerto/api/types" @@ -67,16 +68,33 @@ func getProcessIDFilePath() string { } // Returns the full path to the tmp directory -func getProcessingFolderFilePath() string { - dir := strings.Join([]string{os.TempDir(), string(os.PathSeparator), "imco", string(os.PathSeparator)}, "") - os.Mkdir(dir, 0777) - return dir +func generateWorkspaceDir() (string, error) { + dir := filepath.Join(os.TempDir(), "imco") + dirInfo, err := os.Stat(dir) + if err != nil { + err := os.Mkdir(dir, 0777) + if err != nil { + return "", err + } + } else { + if !dirInfo.Mode().IsDir() { + return "", fmt.Errorf("%s exists but is not a directory", dir) + } + } + return dir, nil } // Start the bootstrapping process func start(c *cli.Context) error { log.Debug("start") + // TODO: replace /etc/imco with a directory taken from configuration/that depends on OS + lockFile, err := singleinstance.CreateLockFile(filepath.Join("/etc/imco", "imco-bootstrapping.lock")) + if err != nil { + return err + } + defer lockFile.Close() + formatter := format.GetFormatter() if err := utils.SetProcessIdToFile(getProcessIDFilePath()); err != nil { formatter.PrintFatal("cannot create the pid file", err) @@ -147,16 +165,20 @@ func applyPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, formatte // Inquire about desired configuration changes to be applied by querying the `GET /blueprint/configuration` endpoint. This will provide a JSON response with the desired configuration changes bsConfiguration, status, err := bootstrappingSvc.GetBootstrappingConfiguration() if err == nil && status != 200 { - err = fmt.Errorf("received non-ok %d response") + err = fmt.Errorf("received non-ok %d response", status) } if err != nil { formatter.PrintError("couldn't receive bootstrapping data", err) return err } + dir, err := generateWorkspaceDir() + if err != nil { + return err + } bsProcess := &bootstrappingProcess{ startedAt: time.Now().UTC(), thresholdLines: thresholdLines, - directoryPath: getProcessingFolderFilePath(), + directoryPath: dir, appliedPolicyfileRevisionIDs: make(map[string]string), } From 5365e95b8f11d3b26be623ea5e184c4f01d97ece Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Ban=CC=83os=20Lo=CC=81pez?= Date: Wed, 6 Mar 2019 16:50:02 +0100 Subject: [PATCH 18/30] Fix bootstrapping issues (issue #90) --- bootstrapping/bootstrapping.go | 45 +++++++++++++++++----------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/bootstrapping/bootstrapping.go b/bootstrapping/bootstrapping.go index bb85bff..43d7869 100644 --- a/bootstrapping/bootstrapping.go +++ b/bootstrapping/bootstrapping.go @@ -185,26 +185,31 @@ func applyPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, formatte // proto structures err = initializePrototype(bsConfiguration, bsProcess) if err != nil { + log.Debug(err) return err } // For every policyfile, ensure its tarball (downloadable through their download_url) has been downloaded to the server ... err = downloadPolicyfiles(bootstrappingSvc, bsProcess) if err != nil { + log.Debug(err) return err } //... and clean off any tarball that is no longer needed. err = cleanObsoletePolicyfiles(bsProcess) if err != nil { + log.Debug(err) return err } // Store the attributes as JSON in a file with name `attrs-.json` err = saveAttributes(bsProcess) if err != nil { + log.Debug(err) return err } // Process tarballs policies err = processPolicyfiles(bootstrappingSvc, bsProcess) if err != nil { + log.Debug(err) return err } // Finishing time @@ -214,6 +219,7 @@ func applyPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, formatte log.Debug("reporting applied policy files") err = reportAppliedConfiguration(bootstrappingSvc, bsProcess) if err != nil { + log.Debug(err) return err } return completeBootstrappingSequence(bsProcess) @@ -261,35 +267,28 @@ func downloadPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, bsPro // cleanObsoletePolicyfiles cleans off any tarball that is no longer needed. func cleanObsoletePolicyfiles(bsProcess *bootstrappingProcess) error { - log.Debug("cleanObsoletePolicyfiles") + log.Debug("cleanObsoletePolicyFiles") - // evaluates working directory + // evaluates working folder deletableFiles, err := ioutil.ReadDir(bsProcess.directoryPath) if err != nil { return err } - // removes from deletableFiles those files we are going to use - for _, bsPolicyfile := range bsProcess.policyfiles { - for i, file := range deletableFiles { - if file.Name() == bsPolicyfile.FileName() { - deletableFiles[i] = deletableFiles[len(deletableFiles)-1] - deletableFiles = deletableFiles[:len(deletableFiles)-1] - break - } - if file.Name() == bsPolicyfile.Name() { - deletableFiles[i] = deletableFiles[len(deletableFiles)-1] - deletableFiles = deletableFiles[:len(deletableFiles)-1] - break - } - } + // builds an array of currently processable files at this looping time + currentlyProcessableFiles := []string{bsProcess.attributes.FileName()} // saved attributes file name + for _, bsPolicyFile := range bsProcess.policyfiles { + currentlyProcessableFiles = append(currentlyProcessableFiles, bsPolicyFile.FileName()) // Downloaded tgz file names + currentlyProcessableFiles = append(currentlyProcessableFiles, bsPolicyFile.Name()) // Uncompressed folder names } // removes from deletableFiles array the policy files currently applied for _, f := range deletableFiles { - log.Debug("removing: ", f.Name()) - if err := os.RemoveAll(strings.Join([]string{bsProcess.directoryPath, string(os.PathSeparator), f.Name()}, "")); err != nil { - return err + if !utils.Contains(currentlyProcessableFiles, f.Name()) { + log.Debug("removing: ", f.Name()) + if err := os.RemoveAll(strings.Join([]string{bsProcess.directoryPath, string(os.PathSeparator), f.Name()}, "")); err != nil { + return err + } } } return nil @@ -314,11 +313,11 @@ func processPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, bsProc log.Debug("processPolicyfiles") for _, bsPolicyfile := range bsProcess.policyfiles { - command := strings.Join([]string{"cd", bsPolicyfile.Path(bsProcess.directoryPath)}, " ") + command := fmt.Sprintf("cd %s", bsPolicyfile.Path(bsProcess.directoryPath)) if runtime.GOOS == "windows" { - command = strings.Join([]string{command, "SET \"PATH=%PATH%;C:\\ruby\\bin;C:\\opscode\\chef\\bin;C:\\opscode\\chef\\embedded\\bin\""}, ";") + command = fmt.Sprintf("%s\nSET \"PATH=%PATH%;C:\\ruby\\bin;C:\\opscode\\chef\\bin;C:\\opscode\\chef\\embedded\\bin\"", command) } - command = strings.Join([]string{command, strings.Join([]string{"chef-client -z -j", bsProcess.attributes.FilePath(bsProcess.directoryPath)}, " ")}, ";") + command = fmt.Sprintf("%s\nchef-client -z -j %s", command, bsProcess.attributes.FilePath(bsProcess.directoryPath)) log.Debug(command) // Custom method for chunks processing @@ -409,7 +408,7 @@ func (pf *policyfile) QueryURL() (string, error) { if err != nil { return "", fmt.Errorf("parsing URL to extract query: %v", err) } - return strings.Join([]string{url.Path[1:], url.RawQuery}, "?"), nil + return fmt.Sprintf("%s?%s", url.Path, url.RawQuery), nil } func (pf *policyfile) TarballPath(dir string) string { From b5ced19ce5ab9ab48831c02c573ab09a108c25ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Ban=CC=83os=20Lo=CC=81pez?= Date: Wed, 6 Mar 2019 17:01:37 +0100 Subject: [PATCH 19/30] Make bootstrapping report applied policyfiles when some fail (issue #90) --- bootstrapping/bootstrapping.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/bootstrapping/bootstrapping.go b/bootstrapping/bootstrapping.go index 43d7869..309aefb 100644 --- a/bootstrapping/bootstrapping.go +++ b/bootstrapping/bootstrapping.go @@ -208,20 +208,19 @@ func applyPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, formatte } // Process tarballs policies err = processPolicyfiles(bootstrappingSvc, bsProcess) - if err != nil { - log.Debug(err) - return err - } // Finishing time bsProcess.finishedAt = time.Now().UTC() // Inform the platform of applied changes via a `PUT /blueprint/applied_configuration` request with a JSON payload similar to log.Debug("reporting applied policy files") - err = reportAppliedConfiguration(bootstrappingSvc, bsProcess) - if err != nil { + reportErr := reportAppliedConfiguration(bootstrappingSvc, bsProcess) + if reportErr != nil { log.Debug(err) return err } + if err != nil { + return err + } return completeBootstrappingSequence(bsProcess) } @@ -286,7 +285,7 @@ func cleanObsoletePolicyfiles(bsProcess *bootstrappingProcess) error { for _, f := range deletableFiles { if !utils.Contains(currentlyProcessableFiles, f.Name()) { log.Debug("removing: ", f.Name()) - if err := os.RemoveAll(strings.Join([]string{bsProcess.directoryPath, string(os.PathSeparator), f.Name()}, "")); err != nil { + if err := os.RemoveAll(filepath.Join(bsProcess.directoryPath, f.Name())); err != nil { return err } } From b8f8aaaa4633c3b7b4e72844e205022e4ad1e9c3 Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Wed, 6 Mar 2019 18:42:57 +0100 Subject: [PATCH 20/30] Refactored single instance management (issue #90) --- bootstrapping/bootstrapping.go | 92 ++++++++++++++++------------------ 1 file changed, 44 insertions(+), 48 deletions(-) diff --git a/bootstrapping/bootstrapping.go b/bootstrapping/bootstrapping.go index 309aefb..3c571a0 100644 --- a/bootstrapping/bootstrapping.go +++ b/bootstrapping/bootstrapping.go @@ -30,7 +30,7 @@ const ( DefaultTimingInterval = 600 // 600 seconds = 10 minutes DefaultTimingSplay = 360 // seconds DefaultThresholdLines = 10 - ProcessIDFile = "imco-bootstrapping.pid" + ProcessLockFile = "imco-bootstrapping.lock" RetriesNumber = 5 ) @@ -48,9 +48,44 @@ type attributes struct { rawData *json.RawMessage } +var allPolicyfilesSuccessfullyApplied bool + type policyfile types.BootstrappingPolicyfile -var allPolicyfilesSuccessfullyApplied bool +func (pf policyfile) Name() string { + return strings.Join([]string{pf.ID, "-", pf.RevisionID}, "") +} + +func (pf *policyfile) FileName() string { + return strings.Join([]string{pf.Name(), "tgz"}, ".") +} + +func (pf *policyfile) QueryURL() (string, error) { + if pf.DownloadURL == "" { + return "", fmt.Errorf("obtaining URL query: empty download URL") + } + url, err := url.Parse(pf.DownloadURL) + if err != nil { + return "", fmt.Errorf("parsing URL to extract query: %v", err) + } + return fmt.Sprintf("%s?%s", url.Path, url.RawQuery), nil +} + +func (pf *policyfile) TarballPath(dir string) string { + return filepath.Join(dir, pf.FileName()) +} + +func (pf *policyfile) Path(dir string) string { + return filepath.Join(dir, pf.Name()) +} + +func (a *attributes) FileName() string { + return fmt.Sprintf("attrs-%s.json", a.revisionID) +} + +func (a *attributes) FilePath(dir string) string { + return filepath.Join(dir, a.FileName()) +} // Handle signals func handleSysSignals(cancelFunc context.CancelFunc) { @@ -62,9 +97,9 @@ func handleSysSignals(cancelFunc context.CancelFunc) { cancelFunc() } -// Returns the full path to the tmp directory joined with pid management file name -func getProcessIDFilePath() string { - return strings.Join([]string{os.TempDir(), string(os.PathSeparator), ProcessIDFile}, "") +// Returns the full path to the tmp directory joined with lock management file name +func getProcessLockFilePath() string { + return filepath.Join(os.TempDir(), string(os.PathSeparator), ProcessLockFile) } // Returns the full path to the tmp directory @@ -88,17 +123,13 @@ func generateWorkspaceDir() (string, error) { func start(c *cli.Context) error { log.Debug("start") - // TODO: replace /etc/imco with a directory taken from configuration/that depends on OS - lockFile, err := singleinstance.CreateLockFile(filepath.Join("/etc/imco", "imco-bootstrapping.lock")) + lockFile, err := singleinstance.CreateLockFile(getProcessLockFilePath()) if err != nil { return err } defer lockFile.Close() formatter := format.GetFormatter() - if err := utils.SetProcessIdToFile(getProcessIDFilePath()); err != nil { - formatter.PrintFatal("cannot create the pid file", err) - } ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -150,7 +181,7 @@ func stop(c *cli.Context) error { log.Debug("cmdStop") formatter := format.GetFormatter() - if err := utils.StopProcess(getProcessIDFilePath()); err != nil { + if err := utils.StopProcess(getProcessLockFilePath()); err != nil { formatter.PrintFatal("cannot stop the bootstrapping process", err) } @@ -266,7 +297,7 @@ func downloadPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, bsPro // cleanObsoletePolicyfiles cleans off any tarball that is no longer needed. func cleanObsoletePolicyfiles(bsProcess *bootstrappingProcess) error { - log.Debug("cleanObsoletePolicyFiles") + log.Debug("cleanObsoletePolicyfiles") // evaluates working folder deletableFiles, err := ioutil.ReadDir(bsProcess.directoryPath) @@ -389,39 +420,4 @@ func completeBootstrappingSequence(bsProcess *bootstrappingProcess) error { allPolicyfilesSuccessfullyApplied = true } return nil -} - -func (pf policyfile) Name() string { - return strings.Join([]string{pf.ID, "-", pf.RevisionID}, "") -} - -func (pf *policyfile) FileName() string { - return strings.Join([]string{pf.Name(), "tgz"}, ".") -} - -func (pf *policyfile) QueryURL() (string, error) { - if pf.DownloadURL == "" { - return "", fmt.Errorf("obtaining URL query: empty download URL") - } - url, err := url.Parse(pf.DownloadURL) - if err != nil { - return "", fmt.Errorf("parsing URL to extract query: %v", err) - } - return fmt.Sprintf("%s?%s", url.Path, url.RawQuery), nil -} - -func (pf *policyfile) TarballPath(dir string) string { - return filepath.Join(dir, pf.FileName()) -} - -func (pf *policyfile) Path(dir string) string { - return filepath.Join(dir, pf.Name()) -} - -func (a *attributes) FileName() string { - return fmt.Sprintf("attrs-%s.json", a.revisionID) -} - -func (a *attributes) FilePath(dir string) string { - return filepath.Join(dir, a.FileName()) -} +} \ No newline at end of file From b51bed764038dc156a4b4019ae3a703b74ccad1a Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Wed, 6 Mar 2019 19:24:08 +0100 Subject: [PATCH 21/30] Make more readable the processing error messages (issue #90) --- bootstrapping/bootstrapping.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bootstrapping/bootstrapping.go b/bootstrapping/bootstrapping.go index 3c571a0..c61ebf6 100644 --- a/bootstrapping/bootstrapping.go +++ b/bootstrapping/bootstrapping.go @@ -204,6 +204,7 @@ func applyPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, formatte } dir, err := generateWorkspaceDir() if err != nil { + formatter.PrintError("couldn't generated workspace directory", err) return err } bsProcess := &bootstrappingProcess{ @@ -216,25 +217,25 @@ func applyPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, formatte // proto structures err = initializePrototype(bsConfiguration, bsProcess) if err != nil { - log.Debug(err) + formatter.PrintError("couldn't initialize prototype", err) return err } // For every policyfile, ensure its tarball (downloadable through their download_url) has been downloaded to the server ... err = downloadPolicyfiles(bootstrappingSvc, bsProcess) if err != nil { - log.Debug(err) + formatter.PrintError("couldn't download policy files", err) return err } //... and clean off any tarball that is no longer needed. err = cleanObsoletePolicyfiles(bsProcess) if err != nil { - log.Debug(err) + formatter.PrintError("couldn't clean obsolete policy files", err) return err } // Store the attributes as JSON in a file with name `attrs-.json` err = saveAttributes(bsProcess) if err != nil { - log.Debug(err) + formatter.PrintError("couldn't save attributes for policy files", err) return err } // Process tarballs policies @@ -246,10 +247,11 @@ func applyPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, formatte log.Debug("reporting applied policy files") reportErr := reportAppliedConfiguration(bootstrappingSvc, bsProcess) if reportErr != nil { - log.Debug(err) + formatter.PrintError("couldn't report applied status for policy files", err) return err } if err != nil { + formatter.PrintError("couldn't process policy files", err) return err } return completeBootstrappingSequence(bsProcess) From 8a22cbe339c33e098b05d134ec1e8b50bc6a470d Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Thu, 7 Mar 2019 10:11:40 +0100 Subject: [PATCH 22/30] Added context management (issue #90) Used with CommandContext at uncompressing "tgz" time --- bootstrapping/bootstrapping.go | 10 +++++----- utils/utils.go | 9 ++++----- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/bootstrapping/bootstrapping.go b/bootstrapping/bootstrapping.go index c61ebf6..50ab34d 100644 --- a/bootstrapping/bootstrapping.go +++ b/bootstrapping/bootstrapping.go @@ -155,7 +155,7 @@ func start(c *cli.Context) error { r := rand.New(rand.NewSource(time.Now().UnixNano())) bootstrappingSvc, formatter := cmd.WireUpBootstrapping(c) for { - applyPolicyfiles(bootstrappingSvc, formatter, thresholdLines) + applyPolicyfiles(ctx, bootstrappingSvc, formatter, thresholdLines) // Sleep for a configured amount of time plus a random amount of time (10 minutes plus 0 to 5 minutes, for instance) ticker := time.NewTicker(time.Duration(timingInterval+int64(r.Intn(int(timingSplay)))) * time.Second) @@ -190,7 +190,7 @@ func stop(c *cli.Context) error { } // Subsidiary routine for commands processing -func applyPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, formatter format.Formatter, thresholdLines int) error { +func applyPolicyfiles(ctx context.Context, bootstrappingSvc *blueprint.BootstrappingService, formatter format.Formatter, thresholdLines int) error { log.Debug("applyPolicyfiles") // Inquire about desired configuration changes to be applied by querying the `GET /blueprint/configuration` endpoint. This will provide a JSON response with the desired configuration changes @@ -221,7 +221,7 @@ func applyPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, formatte return err } // For every policyfile, ensure its tarball (downloadable through their download_url) has been downloaded to the server ... - err = downloadPolicyfiles(bootstrappingSvc, bsProcess) + err = downloadPolicyfiles(ctx, bootstrappingSvc, bsProcess) if err != nil { formatter.PrintError("couldn't download policy files", err) return err @@ -273,7 +273,7 @@ func initializePrototype(bsConfiguration *types.BootstrappingConfiguration, bsPr } // downloadPolicyfiles For every policy file, ensure its tarball (downloadable through their download_url) has been downloaded to the server ... -func downloadPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, bsProcess *bootstrappingProcess) error { +func downloadPolicyfiles(ctx context.Context, bootstrappingSvc *blueprint.BootstrappingService, bsProcess *bootstrappingProcess) error { log.Debug("downloadPolicyfiles") for _, bsPolicyfile := range bsProcess.policyfiles { @@ -290,7 +290,7 @@ func downloadPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, bsPro if err != nil { return err } - if err = utils.Untar(tarballPath, bsPolicyfile.Path(bsProcess.directoryPath)); err != nil { + if err = utils.Untar(ctx, tarballPath, bsPolicyfile.Path(bsProcess.directoryPath)); err != nil { return err } } diff --git a/utils/utils.go b/utils/utils.go index ece700e..53cab57 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -2,10 +2,12 @@ package utils import ( "archive/zip" + "context" "fmt" "io" "math/rand" "os" + "os/exec" "path/filepath" "regexp" "strings" @@ -13,8 +15,6 @@ import ( "github.com/codegangsta/cli" - "os/exec" - log "github.com/Sirupsen/logrus" ) @@ -62,14 +62,13 @@ func Unzip(archive, target string) error { return nil } -// TODO using cmd := exec.CommandContext(ctx,... -func Untar(source, target string) error { +func Untar(ctx context.Context, source, target string) error { if err := os.MkdirAll(target, 0600); err != nil { return err } - cmd := exec.Command("tar", "-xzf", source, "-C", target) + cmd := exec.CommandContext(ctx, "tar", "-xzf", source, "-C", target) if err := cmd.Run(); err != nil { return err } From 5607bf006e89ad14054a419f397821b5b1ee7e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Ban=CC=83os=20Lo=CC=81pez?= Date: Thu, 7 Mar 2019 16:30:09 +0100 Subject: [PATCH 23/30] Refactor bootstrapping workspace dir management (issue #90) --- bootstrapping/bootstrapping.go | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/bootstrapping/bootstrapping.go b/bootstrapping/bootstrapping.go index 50ab34d..cd330a9 100644 --- a/bootstrapping/bootstrapping.go +++ b/bootstrapping/bootstrapping.go @@ -97,33 +97,41 @@ func handleSysSignals(cancelFunc context.CancelFunc) { cancelFunc() } -// Returns the full path to the tmp directory joined with lock management file name -func getProcessLockFilePath() string { - return filepath.Join(os.TempDir(), string(os.PathSeparator), ProcessLockFile) +// Returns the full path to the tmp directory joined with pid management file name +func lockFilePath() string { + return filepath.Join(workspaceDir(), ProcessLockFile) +} + +func workspaceDir() string { + return filepath.Join(os.TempDir(), "imco") } // Returns the full path to the tmp directory -func generateWorkspaceDir() (string, error) { - dir := filepath.Join(os.TempDir(), "imco") +func generateWorkspaceDir() error { + dir := workspaceDir() dirInfo, err := os.Stat(dir) if err != nil { err := os.Mkdir(dir, 0777) if err != nil { - return "", err + return err } } else { if !dirInfo.Mode().IsDir() { - return "", fmt.Errorf("%s exists but is not a directory", dir) + return fmt.Errorf("%s exists but is not a directory", dir) } } - return dir, nil + return nil } // Start the bootstrapping process func start(c *cli.Context) error { log.Debug("start") - lockFile, err := singleinstance.CreateLockFile(getProcessLockFilePath()) + err := generateWorkspaceDir() + if err != nil { + return err + } + lockFile, err := singleinstance.CreateLockFile(lockFilePath()) if err != nil { return err } @@ -181,7 +189,7 @@ func stop(c *cli.Context) error { log.Debug("cmdStop") formatter := format.GetFormatter() - if err := utils.StopProcess(getProcessLockFilePath()); err != nil { + if err := utils.StopProcess(lockFilePath()); err != nil { formatter.PrintFatal("cannot stop the bootstrapping process", err) } @@ -202,7 +210,7 @@ func applyPolicyfiles(ctx context.Context, bootstrappingSvc *blueprint.Bootstrap formatter.PrintError("couldn't receive bootstrapping data", err) return err } - dir, err := generateWorkspaceDir() + err = generateWorkspaceDir() if err != nil { formatter.PrintError("couldn't generated workspace directory", err) return err @@ -210,7 +218,7 @@ func applyPolicyfiles(ctx context.Context, bootstrappingSvc *blueprint.Bootstrap bsProcess := &bootstrappingProcess{ startedAt: time.Now().UTC(), thresholdLines: thresholdLines, - directoryPath: dir, + directoryPath: workspaceDir(), appliedPolicyfileRevisionIDs: make(map[string]string), } @@ -422,4 +430,4 @@ func completeBootstrappingSequence(bsProcess *bootstrappingProcess) error { allPolicyfilesSuccessfullyApplied = true } return nil -} \ No newline at end of file +} From 4556ce199b2b40922c36d5cc15c0a878eae60643 Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Fri, 8 Mar 2019 00:05:30 +0100 Subject: [PATCH 24/30] Added test cases (issue #90) --- api/blueprint/bootstrapping_api_mocked.go | 315 +++++++++++++++++++++- api/blueprint/bootstrapping_api_test.go | 28 +- testdata/boostrapping_data.go | 40 +++ 3 files changed, 379 insertions(+), 4 deletions(-) create mode 100644 testdata/boostrapping_data.go diff --git a/api/blueprint/bootstrapping_api_mocked.go b/api/blueprint/bootstrapping_api_mocked.go index 28bf3c3..29ef6b8 100644 --- a/api/blueprint/bootstrapping_api_mocked.go +++ b/api/blueprint/bootstrapping_api_mocked.go @@ -1,3 +1,316 @@ package blueprint -// TODO +import ( + "encoding/json" + "github.com/ingrammicro/concerto/api/types" + "github.com/ingrammicro/concerto/utils" + "github.com/stretchr/testify/assert" + "testing" + "fmt" +) + +// GetBootstrappingConfigurationMocked test mocked function +func GetBootstrappingConfigurationMocked(t *testing.T, bcConfIn *types.BootstrappingConfiguration) *types.BootstrappingConfiguration { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewBootstrappingService(cs) + assert.Nil(err, "Couldn't load bootstrapping service") + assert.NotNil(ds, "Bootstrapping service not instanced") + + // to json + dIn, err := json.Marshal(bcConfIn) + assert.Nil(err, "Bootstrapping test data corrupted") + + // call service + cs.On("Get", "/blueprint/configuration").Return(dIn, 200, nil) + bcConfOut, status, err := ds.GetBootstrappingConfiguration() + assert.Nil(err, "Error getting bootstrapping configuration") + assert.Equal(status, 200, "GetBootstrappingConfiguration returned invalid response") + assert.Equal(bcConfIn, bcConfOut, "GetBootstrappingConfiguration returned different services") + return bcConfOut +} + +// GetBootstrappingConfigurationFailErrMocked test mocked function +func GetBootstrappingConfigurationFailErrMocked(t *testing.T, bcConfIn *types.BootstrappingConfiguration) *types.BootstrappingConfiguration { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewBootstrappingService(cs) + assert.Nil(err, "Couldn't load bootstrapping service") + assert.NotNil(ds, "Bootstrapping service not instanced") + + // to json + dIn, err := json.Marshal(bcConfIn) + assert.Nil(err, "Bootstrapping test data corrupted") + + // call service + cs.On("Get", "/blueprint/configuration").Return(dIn, 404, fmt.Errorf("Mocked error")) + bcConfOut, _, err := ds.GetBootstrappingConfiguration() + + assert.NotNil(err, "We are expecting an error") + assert.Nil(bcConfOut, "Expecting nil output") + assert.Equal(err.Error(), "Mocked error", "Error should be 'Mocked error'") + + return bcConfOut +} + +// GetBootstrappingConfigurationFailStatusMocked test mocked function +func GetBootstrappingConfigurationFailStatusMocked(t *testing.T, bcConfIn *types.BootstrappingConfiguration) *types.BootstrappingConfiguration { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewBootstrappingService(cs) + assert.Nil(err, "Couldn't load bootstrapping service") + assert.NotNil(ds, "Bootstrapping service not instanced") + + // to json + dIn, err := json.Marshal(bcConfIn) + assert.Nil(err, "Bootstrapping test data corrupted") + + // call service + cs.On("Get", "/blueprint/configuration").Return(dIn, 499, nil) + bcConfOut, status, err := ds.GetBootstrappingConfiguration() + + assert.NotNil(err, "We are expecting an status code error") + assert.Nil(bcConfOut, "Expecting nil output") + assert.Equal(499, status, "Expecting http code 499") + assert.Contains(err.Error(), "499", "Error should contain http code 499") + + return bcConfOut +} + +// GetBootstrappingConfigurationFailJSONMocked test mocked function +func GetBootstrappingConfigurationFailJSONMocked(t *testing.T, bcConfIn *types.BootstrappingConfiguration) *types.BootstrappingConfiguration { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewBootstrappingService(cs) + assert.Nil(err, "Couldn't load bootstrapping service") + assert.NotNil(ds, "Bootstrapping service not instanced") + + // wrong json + dIn := []byte{10, 20, 30} + + // call service + cs.On("Get", "/blueprint/configuration").Return(dIn, 200, nil) + bcConfOut, _, err := ds.GetBootstrappingConfiguration() + + assert.NotNil(err, "We are expecting a marshalling error") + assert.Nil(bcConfOut, "Expecting nil output") + + return bcConfOut +} + +// ReportBootstrappingAppliedConfigurationMocked test mocked function +func ReportBootstrappingAppliedConfigurationMocked(t *testing.T, commandIn *types.BootstrappingConfiguration) { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewBootstrappingService(cs) + assert.Nil(err, "Couldn't load bootstrapping service") + assert.NotNil(ds, "Bootstrapping service not instanced") + + // to json + dOut, err := json.Marshal(commandIn) + assert.Nil(err, "Bootstrapping test data corrupted") + + // call service + payload := make(map[string]interface{}) + cs.On("Put", fmt.Sprintf("/blueprint/applied_configuration"), &payload).Return(dOut, 200, nil) + err = ds.ReportBootstrappingAppliedConfiguration(&payload) + assert.Nil(err, "Error getting bootstrapping command") +} + +// ReportBootstrappingAppliedConfigurationFailErrMocked test mocked function +func ReportBootstrappingAppliedConfigurationFailErrMocked(t *testing.T, commandIn *types.BootstrappingConfiguration) { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewBootstrappingService(cs) + assert.Nil(err, "Couldn't load bootstrapping service") + assert.NotNil(ds, "Bootstrapping service not instanced") + + // to json + dIn, err := json.Marshal(commandIn) + assert.Nil(err, "Bootstrapping test data corrupted") + + dIn = nil + + // call service + payload := make(map[string]interface{}) + cs.On("Put", fmt.Sprintf("/blueprint/applied_configuration"), &payload).Return(dIn, 400, fmt.Errorf("Mocked error")) + err = ds.ReportBootstrappingAppliedConfiguration(&payload) + assert.NotNil(err, "We are expecting an error") + assert.Equal(err.Error(), "Mocked error", "Error should be 'Mocked error'") +} + +// ReportBootstrappingAppliedConfigurationFailStatusMocked test mocked function +func ReportBootstrappingAppliedConfigurationFailStatusMocked(t *testing.T, commandIn *types.BootstrappingConfiguration) { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewBootstrappingService(cs) + assert.Nil(err, "Couldn't load bootstrapping service") + assert.NotNil(ds, "Bootstrapping service not instanced") + + // to json + dIn, err := json.Marshal(commandIn) + assert.Nil(err, "Bootstrapping test data corrupted") + + dIn = nil + + // call service + payload := make(map[string]interface{}) + cs.On("Put", fmt.Sprintf("/blueprint/applied_configuration"), &payload).Return(dIn, 499, fmt.Errorf("Error 499 Mocked error")) + err = ds.ReportBootstrappingAppliedConfiguration(&payload) + assert.NotNil(err, "We are expecting a status code error") + assert.Contains(err.Error(), "499", "Error should contain http code 499") +} + +// ReportBootstrappingAppliedConfigurationFailJSONMocked test mocked function +func ReportBootstrappingAppliedConfigurationFailJSONMocked(t *testing.T, commandIn *types.BootstrappingConfiguration) { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewBootstrappingService(cs) + assert.Nil(err, "Couldn't load bootstrapping service") + assert.NotNil(ds, "Bootstrapping service not instanced") + + // wrong json + dIn := []byte{0} + + // call service + payload := make(map[string]interface{}) + cs.On("Put", fmt.Sprintf("/blueprint/applied_configuration"), &payload).Return(dIn, 499, nil) + err = ds.ReportBootstrappingAppliedConfiguration(&payload) + assert.Contains(err.Error(), "499", "Error should contain http code 499") +} + +// ReportBootstrappingLogMocked test mocked function +func ReportBootstrappingLogMocked(t *testing.T, commandIn *types.BootstrappingContinuousReport) *types.BootstrappingContinuousReport { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewBootstrappingService(cs) + assert.Nil(err, "Couldn't load bootstrapping service") + assert.NotNil(ds, "Bootstrapping service not instanced") + + // to json + dOut, err := json.Marshal(commandIn) + assert.Nil(err, "Bootstrapping test data corrupted") + + // call service + payload := make(map[string]interface{}) + cs.On("Post", fmt.Sprintf("/blueprint/bootstrap_logs"), &payload).Return(dOut, 200, nil) + commandOut, status, err := ds.ReportBootstrappingLog(&payload) + + assert.Nil(err, "Error posting report command") + assert.Equal(status, 200, "ReportBootstrappingLog returned invalid response") + assert.Equal(commandOut.Stdout, "Bootstrap log created", "ReportBootstrapLog returned unexpected message") + + return commandOut +} + +// ReportBootstrappingLogFailErrMocked test mocked function +func ReportBootstrappingLogFailErrMocked(t *testing.T, commandIn *types.BootstrappingContinuousReport) *types.BootstrappingContinuousReport { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewBootstrappingService(cs) + assert.Nil(err, "Couldn't load bootstrapping service") + assert.NotNil(ds, "Bootstrapping service not instanced") + + // to json + dIn, err := json.Marshal(commandIn) + assert.Nil(err, "Bootstrapping test data corrupted") + + dIn = nil + + // call service + payload := make(map[string]interface{}) + cs.On("Post", fmt.Sprintf("/blueprint/bootstrap_logs"), &payload).Return(dIn, 400, fmt.Errorf("Mocked error")) + commandOut, _, err := ds.ReportBootstrappingLog(&payload) + + assert.NotNil(err, "We are expecting an error") + assert.Nil(commandOut, "Expecting nil output") + assert.Equal(err.Error(), "Mocked error", "Error should be 'Mocked error'") + + return commandOut +} + +// ReportBootstrappingLogFailStatusMocked test mocked function +func ReportBootstrappingLogFailStatusMocked(t *testing.T, commandIn *types.BootstrappingContinuousReport) *types.BootstrappingContinuousReport { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewBootstrappingService(cs) + assert.Nil(err, "Couldn't load bootstrapping service") + assert.NotNil(ds, "Bootstrapping service not instanced") + + // to json + dIn, err := json.Marshal(commandIn) + assert.Nil(err, "Bootstrapping test data corrupted") + + dIn = nil + + // call service + payload := make(map[string]interface{}) + cs.On("Post", fmt.Sprintf("/blueprint/bootstrap_logs"), &payload).Return(dIn, 499, fmt.Errorf("Error 499 Mocked error")) + commandOut, status, err := ds.ReportBootstrappingLog(&payload) + + assert.Equal(status, 499, "ReportBootstrappingLog returned an unexpected status code") + assert.NotNil(err, "We are expecting a status code error") + assert.Nil(commandOut, "Expecting nil output") + assert.Contains(err.Error(), "499", "Error should contain http code 499") + + return commandOut +} + +// ReportBootstrappingLogFailJSONMocked test mocked function +func ReportBootstrappingLogFailJSONMocked(t *testing.T, commandIn *types.BootstrappingContinuousReport) *types.BootstrappingContinuousReport { + + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewBootstrappingService(cs) + assert.Nil(err, "Couldn't load bootstrapping service") + assert.NotNil(ds, "Bootstrapping service not instanced") + + // wrong json + dIn := []byte{10, 20, 30} + + // call service + payload := make(map[string]interface{}) + cs.On("Post", fmt.Sprintf("/blueprint/bootstrap_logs"), &payload).Return(dIn, 200, nil) + commandOut, _, err := ds.ReportBootstrappingLog(&payload) + + assert.NotNil(err, "We are expecting a marshalling error") + assert.Nil(commandOut, "Expecting nil output") + assert.Contains(err.Error(), "invalid character", "Error message should include the string 'invalid character'") + + return commandOut +} \ No newline at end of file diff --git a/api/blueprint/bootstrapping_api_test.go b/api/blueprint/bootstrapping_api_test.go index 74ba96c..fa4e0b4 100644 --- a/api/blueprint/bootstrapping_api_test.go +++ b/api/blueprint/bootstrapping_api_test.go @@ -1,9 +1,9 @@ package blueprint import ( - "testing" - + "github.com/ingrammicro/concerto/testdata" "github.com/stretchr/testify/assert" + "testing" ) func TestNewBootstrappingServiceNil(t *testing.T) { @@ -13,4 +13,26 @@ func TestNewBootstrappingServiceNil(t *testing.T) { assert.NotNil(err, "Uninitialized service should return error") } -// TODO +func TestGetBootstrappingConfiguration(t *testing.T) { + bcIn := testdata.GetBootstrappingConfigurationData() + GetBootstrappingConfigurationMocked(t, bcIn) + GetBootstrappingConfigurationFailErrMocked(t, bcIn) + GetBootstrappingConfigurationFailStatusMocked(t, bcIn) + GetBootstrappingConfigurationFailJSONMocked(t, bcIn) +} + +func TestReportBootstrappingAppliedConfiguration(t *testing.T) { + bcIn := testdata.GetBootstrappingConfigurationData() + ReportBootstrappingAppliedConfigurationMocked(t, bcIn) + ReportBootstrappingAppliedConfigurationFailErrMocked(t, bcIn) + ReportBootstrappingAppliedConfigurationFailStatusMocked(t, bcIn) + ReportBootstrappingAppliedConfigurationFailJSONMocked(t, bcIn) +} + +func TestReportBootstrappingLog(t *testing.T) { + commandIn := testdata.GetBootstrappingContinuousReportData() + ReportBootstrappingLogMocked(t, commandIn) + ReportBootstrappingLogFailErrMocked(t, commandIn) + ReportBootstrappingLogFailStatusMocked(t, commandIn) + ReportBootstrappingLogFailJSONMocked(t, commandIn) +} \ No newline at end of file diff --git a/testdata/boostrapping_data.go b/testdata/boostrapping_data.go new file mode 100644 index 0000000..4e542ff --- /dev/null +++ b/testdata/boostrapping_data.go @@ -0,0 +1,40 @@ +package testdata + +import ( + "github.com/ingrammicro/concerto/api/types" + "encoding/json" +) + +// GetBootstrappingConfigurationData loads test data +func GetBootstrappingConfigurationData() *types.BootstrappingConfiguration { + + attrs := json.RawMessage(`{"fakeAttribute0":"val0","fakeAttribute1":"val1"}`) + test := types.BootstrappingConfiguration{ + Policyfiles: []types.BootstrappingPolicyfile{ + { + ID: "fakeProfileID0", + RevisionID: "fakeProfileRevisionID0", + DownloadURL: "fakeProfileDownloadURL0", + }, + { + ID: "fakeProfileID1", + RevisionID: "fakeProfileRevisionID1", + DownloadURL: "fakeProfileDownloadURL1", + }, + }, + Attributes: &attrs, + AttributeRevisionID: "fakeAttributeRevisionID", + } + + return &test +} + +// GetBootstrappingContinuousReportData loads test data +func GetBootstrappingContinuousReportData() *types.BootstrappingContinuousReport{ + + testBootstrappingContinuousReport := types.BootstrappingContinuousReport{ + Stdout: "Bootstrap log created", + } + + return &testBootstrappingContinuousReport +} From 1955329925955cb73c01cb56f444beecb11acfa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Ban=CC=83os=20Lo=CC=81pez?= Date: Fri, 8 Mar 2019 11:48:58 +0100 Subject: [PATCH 25/30] Remove scripts boot execution in bootstrapping and move config files to cio directory --- README.md | 4 ++-- bootstrapping/bootstrapping.go | 27 ++++----------------------- cmdpolling/polling.go | 2 +- utils/config.go | 18 +++++++++--------- 4 files changed, 16 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index d9fdd43..a10ee2b 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Extract the contents with your zip compressor of choice and continue using the s ### Configuration -IMCO CLI configuration will usually be located in your personal folder under `.concerto`. If you are using root, CLI will look for contiguration files under `/etc/imco`. +IMCO CLI configuration will usually be located in your personal folder under `.concerto`. If you are using root, CLI will look for contiguration files under `/etc/cio`. We will assume that you are not root, so create the folder and drop the certificates to this location: ```bash @@ -184,7 +184,7 @@ If you got an error executing IMCO CLI: - check that your internet connection can reach `clients.{IMCO_DOMAIN}` - make sure that your firewall lets you access to - check that `client.xml` is pointing to the correct certificates location -- if `concerto` executes but only shows server commands, you are probably trying to use `concerto` from a commissioned server, and the configuration is being read from `/etc/imco`. If that's the case, you should leave `concerto` configuration untouched so that server commands are available for our remote management. +- if `concerto` executes but only shows server commands, you are probably trying to use `concerto` from a commissioned server, and the configuration is being read from `/etc/cio`. If that's the case, you should leave `concerto` configuration untouched so that server commands are available for our remote management. ## Usage diff --git a/bootstrapping/bootstrapping.go b/bootstrapping/bootstrapping.go index cd330a9..d272113 100644 --- a/bootstrapping/bootstrapping.go +++ b/bootstrapping/bootstrapping.go @@ -30,7 +30,7 @@ const ( DefaultTimingInterval = 600 // 600 seconds = 10 minutes DefaultTimingSplay = 360 // seconds DefaultThresholdLines = 10 - ProcessLockFile = "imco-bootstrapping.lock" + ProcessLockFile = "cio-bootstrapping.lock" RetriesNumber = 5 ) @@ -103,7 +103,7 @@ func lockFilePath() string { } func workspaceDir() string { - return filepath.Join(os.TempDir(), "imco") + return filepath.Join(os.TempDir(), "cio") } // Returns the full path to the tmp directory @@ -258,11 +258,7 @@ func applyPolicyfiles(ctx context.Context, bootstrappingSvc *blueprint.Bootstrap formatter.PrintError("couldn't report applied status for policy files", err) return err } - if err != nil { - formatter.PrintError("couldn't process policy files", err) - return err - } - return completeBootstrappingSequence(bsProcess) + return err } func initializePrototype(bsConfiguration *types.BootstrappingConfiguration, bsProcess *bootstrappingProcess) error { @@ -355,7 +351,7 @@ func processPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, bsProc for _, bsPolicyfile := range bsProcess.policyfiles { command := fmt.Sprintf("cd %s", bsPolicyfile.Path(bsProcess.directoryPath)) if runtime.GOOS == "windows" { - command = fmt.Sprintf("%s\nSET \"PATH=%PATH%;C:\\ruby\\bin;C:\\opscode\\chef\\bin;C:\\opscode\\chef\\embedded\\bin\"", command) + command = fmt.Sprintf("%s\nSET \"PATH=%%PATH%%;C:\\ruby\\bin;C:\\opscode\\chef\\bin;C:\\opscode\\chef\\embedded\\bin\"", command) } command = fmt.Sprintf("%s\nchef-client -z -j %s", command, bsProcess.attributes.FilePath(bsProcess.directoryPath)) log.Debug(command) @@ -416,18 +412,3 @@ func reportAppliedConfiguration(bootstrappingSvc *blueprint.BootstrappingService } return bootstrappingSvc.ReportBootstrappingAppliedConfiguration(&payload) } - -// completeBootstrappingSequence evaluates if the first iteration of policies was completed; If case, execute the "scripts boot" command. -func completeBootstrappingSequence(bsProcess *bootstrappingProcess) error { - log.Debug("completeBootstrappingSequence") - - if !allPolicyfilesSuccessfullyApplied { - log.Debug("run the boot scripts") - //run the boot scripts for the server by executing the scripts boot sub-command (as an external process). - if output, exit, _, _ := utils.RunCmd(strings.Join([]string{os.Args[0], "scripts", "boot"}, " ")); exit != 0 { - return fmt.Errorf("boot scripts run failed with exit code %d and following output: %s", exit, output) - } - allPolicyfilesSuccessfullyApplied = true - } - return nil -} diff --git a/cmdpolling/polling.go b/cmdpolling/polling.go index 07f6274..c97990b 100644 --- a/cmdpolling/polling.go +++ b/cmdpolling/polling.go @@ -19,7 +19,7 @@ import ( const ( DefaultPollingPingTimingIntervalLong = 30 DefaultPollingPingTimingIntervalShort = 5 - ProcessIdFile = "imco-polling.pid" + ProcessIdFile = "cio-polling.pid" ) // Handle signals diff --git a/utils/config.go b/utils/config.go index ae8fd8f..9523b52 100644 --- a/utils/config.go +++ b/utils/config.go @@ -20,18 +20,18 @@ import ( "github.com/mitchellh/go-homedir" ) -const windowsServerConfigFile = "c:\\imco\\client.xml" -const nixServerConfigFile = "/etc/imco/client.xml" +const windowsServerConfigFile = "c:\\cio\\client.xml" +const nixServerConfigFile = "/etc/cio/client.xml" const defaultConcertoEndpoint = "https://clients.concerto.io:886/" -const windowsServerLogFilePath = "c:\\imco\\log\\concerto-client.log" -const windowsServerCaCertPath = "c:\\imco\\client_ssl\\ca_cert.pem" -const windowsServerCertPath = "c:\\imco\\client_ssl\\cert.pem" -const windowsServerKeyPath = "c:\\imco\\client_ssl\\private\\key.pem" +const windowsServerLogFilePath = "c:\\cio\\log\\concerto-client.log" +const windowsServerCaCertPath = "c:\\cio\\client_ssl\\ca_cert.pem" +const windowsServerCertPath = "c:\\cio\\client_ssl\\cert.pem" +const windowsServerKeyPath = "c:\\cio\\client_ssl\\private\\key.pem" const nixServerLogFilePath = "/var/log/concerto-client.log" -const nixServerCaCertPath = "/etc/imco/client_ssl/ca_cert.pem" -const nixServerCertPath = "/etc/imco/client_ssl/cert.pem" -const nixServerKeyPath = "/etc/imco/client_ssl/private/key.pem" +const nixServerCaCertPath = "/etc/cio/client_ssl/ca_cert.pem" +const nixServerCertPath = "/etc/cio/client_ssl/cert.pem" +const nixServerKeyPath = "/etc/cio/client_ssl/private/key.pem" // Config stores configuration file contents type Config struct { From 0ec978e7226da6cec326965625f2fee74320cf85 Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Mon, 11 Mar 2019 13:49:44 +0100 Subject: [PATCH 26/30] Completed test cases (issue #90) - Downloading file --- api/blueprint/bootstrapping_api_mocked.go | 50 +++++++++++++++++++++-- api/blueprint/bootstrapping_api_test.go | 8 +++- testdata/boostrapping_data.go | 14 +++++-- 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/api/blueprint/bootstrapping_api_mocked.go b/api/blueprint/bootstrapping_api_mocked.go index 29ef6b8..916ddca 100644 --- a/api/blueprint/bootstrapping_api_mocked.go +++ b/api/blueprint/bootstrapping_api_mocked.go @@ -2,11 +2,11 @@ package blueprint import ( "encoding/json" + "fmt" "github.com/ingrammicro/concerto/api/types" "github.com/ingrammicro/concerto/utils" "github.com/stretchr/testify/assert" "testing" - "fmt" ) // GetBootstrappingConfigurationMocked test mocked function @@ -151,7 +151,7 @@ func ReportBootstrappingAppliedConfigurationFailErrMocked(t *testing.T, commandI // call service payload := make(map[string]interface{}) - cs.On("Put", fmt.Sprintf("/blueprint/applied_configuration"), &payload).Return(dIn, 400, fmt.Errorf("Mocked error")) + cs.On("Put", fmt.Sprintf("/blueprint/applied_configuration"), &payload).Return(dIn, 499, fmt.Errorf("Mocked error")) err = ds.ReportBootstrappingAppliedConfiguration(&payload) assert.NotNil(err, "We are expecting an error") assert.Equal(err.Error(), "Mocked error", "Error should be 'Mocked error'") @@ -249,7 +249,7 @@ func ReportBootstrappingLogFailErrMocked(t *testing.T, commandIn *types.Bootstra // call service payload := make(map[string]interface{}) - cs.On("Post", fmt.Sprintf("/blueprint/bootstrap_logs"), &payload).Return(dIn, 400, fmt.Errorf("Mocked error")) + cs.On("Post", fmt.Sprintf("/blueprint/bootstrap_logs"), &payload).Return(dIn, 499, fmt.Errorf("Mocked error")) commandOut, _, err := ds.ReportBootstrappingLog(&payload) assert.NotNil(err, "We are expecting an error") @@ -313,4 +313,46 @@ func ReportBootstrappingLogFailJSONMocked(t *testing.T, commandIn *types.Bootstr assert.Contains(err.Error(), "invalid character", "Error message should include the string 'invalid character'") return commandOut -} \ No newline at end of file +} + +// DownloadPolicyfileMocked +func DownloadPolicyfileMocked(t *testing.T, dataIn map[string]string) { + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewBootstrappingService(cs) + assert.Nil(err, "Couldn't load bootstrapping service") + assert.NotNil(ds, "Bootstrapping service not instanced") + + urlSource := dataIn["fakeURLToFile"] + pathFile := dataIn["fakeFileDownloadFile"] + + // call service + cs.On("GetFile", urlSource, pathFile).Return(pathFile, 200, nil) + realFileName, status, err := ds.DownloadPolicyfile(urlSource, pathFile) + assert.Nil(err, "Error downloading bootstrapping policy file") + assert.Equal(status, 200, "DownloadPolicyfile returned invalid response") + assert.Equal(realFileName, pathFile, "Invalid downloaded file path") +} + +// DownloadPolicyfileFailErrMocked +func DownloadPolicyfileFailErrMocked(t *testing.T, dataIn map[string]string) { + assert := assert.New(t) + + // wire up + cs := &utils.MockConcertoService{} + ds, err := NewBootstrappingService(cs) + assert.Nil(err, "Couldn't load bootstrapping service") + assert.NotNil(ds, "Bootstrapping service not instanced") + + urlSource := dataIn["fakeURLToFile"] + pathFile := dataIn["fakeFileDownloadFile"] + + // call service + cs.On("GetFile", urlSource, pathFile).Return("", 499, fmt.Errorf("Mocked error")) + _, status, err := ds.DownloadPolicyfile(urlSource, pathFile) + assert.NotNil(err, "We are expecting an error") + assert.Equal(status, 499, "DownloadPolicyfile returned an unexpected status code") + assert.Equal(err.Error(), "Mocked error", "Error should be 'Mocked error'") +} diff --git a/api/blueprint/bootstrapping_api_test.go b/api/blueprint/bootstrapping_api_test.go index fa4e0b4..8815905 100644 --- a/api/blueprint/bootstrapping_api_test.go +++ b/api/blueprint/bootstrapping_api_test.go @@ -35,4 +35,10 @@ func TestReportBootstrappingLog(t *testing.T) { ReportBootstrappingLogFailErrMocked(t, commandIn) ReportBootstrappingLogFailStatusMocked(t, commandIn) ReportBootstrappingLogFailJSONMocked(t, commandIn) -} \ No newline at end of file +} + +func TestDownloadPolicyfile(t *testing.T) { + dataIn := testdata.GetBootstrappingDownloadFileData() + DownloadPolicyfileMocked(t, dataIn) + DownloadPolicyfileFailErrMocked(t, dataIn) +} diff --git a/testdata/boostrapping_data.go b/testdata/boostrapping_data.go index 4e542ff..5467995 100644 --- a/testdata/boostrapping_data.go +++ b/testdata/boostrapping_data.go @@ -1,8 +1,8 @@ package testdata import ( - "github.com/ingrammicro/concerto/api/types" "encoding/json" + "github.com/ingrammicro/concerto/api/types" ) // GetBootstrappingConfigurationData loads test data @@ -10,7 +10,7 @@ func GetBootstrappingConfigurationData() *types.BootstrappingConfiguration { attrs := json.RawMessage(`{"fakeAttribute0":"val0","fakeAttribute1":"val1"}`) test := types.BootstrappingConfiguration{ - Policyfiles: []types.BootstrappingPolicyfile{ + Policyfiles: []types.BootstrappingPolicyfile{ { ID: "fakeProfileID0", RevisionID: "fakeProfileRevisionID0", @@ -30,7 +30,7 @@ func GetBootstrappingConfigurationData() *types.BootstrappingConfiguration { } // GetBootstrappingContinuousReportData loads test data -func GetBootstrappingContinuousReportData() *types.BootstrappingContinuousReport{ +func GetBootstrappingContinuousReportData() *types.BootstrappingContinuousReport { testBootstrappingContinuousReport := types.BootstrappingContinuousReport{ Stdout: "Bootstrap log created", @@ -38,3 +38,11 @@ func GetBootstrappingContinuousReportData() *types.BootstrappingContinuousReport return &testBootstrappingContinuousReport } + +//GetBootstrappingDownloadFileData +func GetBootstrappingDownloadFileData() map[string]string { + return map[string]string{ + "fakeURLToFile": "http://fakeURLToFile.xxx/filename.tgz", + "fakeFileDownloadFile": "filename.tgz", + } +} From 4be9ada7074311603d6dfdcd4e57de156e01191d Mon Sep 17 00:00:00 2001 From: Samuel Patino Date: Thu, 14 Mar 2019 12:48:31 +0100 Subject: [PATCH 27/30] Updated bootstrapping process to works in windows platform (Issue #90) --- bootstrapping/bootstrapping.go | 2 +- utils/utils.go | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/bootstrapping/bootstrapping.go b/bootstrapping/bootstrapping.go index d272113..ffaac0b 100644 --- a/bootstrapping/bootstrapping.go +++ b/bootstrapping/bootstrapping.go @@ -99,7 +99,7 @@ func handleSysSignals(cancelFunc context.CancelFunc) { // Returns the full path to the tmp directory joined with pid management file name func lockFilePath() string { - return filepath.Join(workspaceDir(), ProcessLockFile) + return filepath.Join(os.TempDir(), ProcessLockFile) } func workspaceDir() string { diff --git a/utils/utils.go b/utils/utils.go index 53cab57..f67b5c6 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" "strings" "time" @@ -68,11 +69,15 @@ func Untar(ctx context.Context, source, target string) error { return err } - cmd := exec.CommandContext(ctx, "tar", "-xzf", source, "-C", target) + tarExecutable := "tar" + if runtime.GOOS == "windows" { + tarExecutable = "C:\\opscode\\chef\\bin\\tar.exe" + } + cmd := exec.CommandContext(ctx, tarExecutable, "-xzf", source, "-C", target) if err := cmd.Run(); err != nil { return err } - + return nil } From 041df51f1992308d70c426df44eebf816c0b81ff Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Mon, 18 Mar 2019 10:46:44 +0100 Subject: [PATCH 28/30] Removed unused variable (issue #90) --- bootstrapping/bootstrapping.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/bootstrapping/bootstrapping.go b/bootstrapping/bootstrapping.go index ffaac0b..52c19ca 100644 --- a/bootstrapping/bootstrapping.go +++ b/bootstrapping/bootstrapping.go @@ -48,8 +48,6 @@ type attributes struct { rawData *json.RawMessage } -var allPolicyfilesSuccessfullyApplied bool - type policyfile types.BootstrappingPolicyfile func (pf policyfile) Name() string { From 2ad5989de9936f352d5a521600fe9dc0b18ea1e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Ban=CC=83os=20Lo=CC=81pez?= Date: Tue, 19 Mar 2019 10:21:24 +0100 Subject: [PATCH 29/30] Have bootstrapping server command rename policyfile dir for chef runs on windows (issue #90) --- bootstrapping/bootstrapping.go | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/bootstrapping/bootstrapping.go b/bootstrapping/bootstrapping.go index 52c19ca..b730555 100644 --- a/bootstrapping/bootstrapping.go +++ b/bootstrapping/bootstrapping.go @@ -347,11 +347,20 @@ func processPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, bsProc log.Debug("processPolicyfiles") for _, bsPolicyfile := range bsProcess.policyfiles { - command := fmt.Sprintf("cd %s", bsPolicyfile.Path(bsProcess.directoryPath)) + command := fmt.Sprintf("chef-client -z -j %s", bsProcess.attributes.FilePath(bsProcess.directoryPath)) + policyfileDir := bsPolicyfile.Path(bsProcess.directoryPath) + var renamedPolicyfileDir string if runtime.GOOS == "windows" { - command = fmt.Sprintf("%s\nSET \"PATH=%%PATH%%;C:\\ruby\\bin;C:\\opscode\\chef\\bin;C:\\opscode\\chef\\embedded\\bin\"", command) + renamedPolicyfileDir = policyfileDir + policyfileDir = filepath.Join(bsProcess.directoryPath, "active") + err := os.Rename(renamedPolicyfileDir, policyfileDir) + if err != nil { + return fmt.Errorf("could not rename %s as %s: %v", renamedPolicyfileDir, policyfileDir, err) + } + command = fmt.Sprintf("SET \"PATH=%%PATH%%;C:\\ruby\\bin;C:\\opscode\\chef\\bin;C:\\opscode\\chef\\embedded\\bin\"\n%s", command) } - command = fmt.Sprintf("%s\nchef-client -z -j %s", command, bsProcess.attributes.FilePath(bsProcess.directoryPath)) + command = fmt.Sprintf("cd %s\n%s", policyfileDir, command) + log.Debug(command) // Custom method for chunks processing @@ -394,6 +403,12 @@ func processPolicyfiles(bootstrappingSvc *blueprint.BootstrappingService, bsProc log.Info("completed: ", exitCode) bsProcess.appliedPolicyfileRevisionIDs[bsPolicyfile.ID] = bsPolicyfile.RevisionID + if renamedPolicyfileDir != "" { + err = os.Rename(policyfileDir, renamedPolicyfileDir) + if err != nil { + return fmt.Errorf("could not rename %s as %s back: %v", policyfileDir, renamedPolicyfileDir, err) + } + } } return nil } From 02814dab5a0128ffe7df2726c17a0b757a7ddbd5 Mon Sep 17 00:00:00 2001 From: Pablo Cantera Date: Mon, 25 Mar 2019 11:38:14 +0100 Subject: [PATCH 30/30] Update version to 0.8.0 --- utils/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/version.go b/utils/version.go index efe8ba3..6ef1669 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,3 +1,3 @@ package utils -const VERSION = "0.7.0" +const VERSION = "0.8.0"