Repository: chef/automate
Pull request #4452 (2020-11-25 04:57:17)

Jamie/4343 connected services filter
### :nut_and_bolt: Description: What code changed, and why?
with the addition of https://github.com/chef/automate/pull/4339, we have the backend ability to filter service groups by only connected services.  This branch adds this ability to the UI

small UX bonus - hide the checkbox for deleting selected services when there all no services to select from

### :chains: Related Resources
Backend Work: https://github.com/chef/automate/pull/4339

### :+1: Definition of Done
User is able to filter on connected services in services sidebar.

### :athletic_shoe: How to Build and Test the Change
Terminal 1 - in Automate root folder
`hab studio enter`
`build components/automate-ui-devproxy`
`applications_populate_database`

Terminal 2 - cd into components/automate-ui
`make serve`

Navigate to https://a2-dev.test/applications/service-groups
in the services sidebar, see the "connected" option in the group of filter options
Click on the connected services option to see it filter

### :white_check_mark: Checklist

- [x] I have read the [CONTRIBUTING document](https://github.com/chef/automate/blob/master/CONTRIBUTING.md).
- [ ] Tests added/updated?
- [ ] Docs added/updated?
- [x] All commits have been signed-off for the [Developer Certification of Origin](https://github.com/chef/chef/blob/master/CONTRIBUTING.md#developer-certification-of-origin-dco).

### :camera: Screenshots, if applicable
After:
![Screen Shot 2020-11-25 at 11 32 48 AM](https://user-images.githubusercontent.com/16737484/100273734-1a2cea00-2f12-11eb-9b88-11303ec138ad.png)

netlify[bot]: Deploy preview for *chef-automate* processing.

Building with commit e77ba5bc3f0e11cd6a1a377655d98c64178ac947

https://app.netlify.com/sites/chef-automate/deploys/5fbde42f6c9cae0008b81bc6
msorens: BTW, I would also suggest setting up some persistent data as you have outlined here but on an acceptance box (e.g. a2-local-inplace-upgrade-acceptance.cd.chef.co) so this could be exercised there as well.

Patch:
diff --git a/components/automate-ui/src/app/entities/service-groups/service-groups.facade.ts b/components/automate-ui/src/app/entities/service-groups/service-groups.facade.ts
index 0c3fcff10..b7567885e 100644
--- a/components/automate-ui/src/app/entities/service-groups/service-groups.facade.ts
+++ b/components/automate-ui/src/app/entities/service-groups/service-groups.facade.ts
@@ -2,7 +2,6 @@ import { Injectable } from '@angular/core';
 import { Store } from '@ngrx/store';
 import { HttpErrorResponse } from '@angular/common/http';
 import { Observable } from 'rxjs';
-import { includes } from 'lodash/fp';
 import * as moment from 'moment/moment';
 
 import { EntityStatus } from '../../entities/entities';
@@ -13,7 +12,8 @@ import {
   ServiceGroup,
   ServiceGroupsHealthSummary,
   GroupService,
-  GroupServicesFilters
+  GroupServicesFilters,
+  AllowedStatus
 } from '../../entities/service-groups/service-groups.model';
 import {
   serviceGroupsStatus,
@@ -43,14 +43,10 @@ export class ServiceGroupsFacadeService {
   public svcHealthSummary$: Observable<ServiceGroupsHealthSummary>;
   public currentServicesFilters$: Observable<GroupServicesFilters>;
 
-  // The collection of allowable status
-  public allowedStatus = ['ok', 'critical', 'warning', 'unknown', 'disconnected'];
-
   constructor(
       private store: Store<fromServiceGroups.ServiceGroupsEntityState>,
       private telemetryService: TelemetryService
     ) {
-    // this.allBooks$ = store.pipe(select(fromBooks.getAllBooks));
     this.services$ = store.select(selectedServiceGroupList);
     this.serviceGroupsStatus$ = store.select(serviceGroupsStatus);
     this.serviceGroupsError$ = store.select(serviceGroupsError);
@@ -83,8 +79,7 @@ export class ServiceGroupsFacadeService {
     });
   }
 
-  public updateHealthFilter(health: string, trackEvent: string): string {
-    health = includes(health, this.allowedStatus) ? health : 'total';
+  public updateHealthFilter(health: AllowedStatus, trackEvent: string): AllowedStatus {
     this.telemetryService.track(trackEvent, {
         entity: 'service', statusFilter: health
     });
diff --git a/components/automate-ui/src/app/entities/service-groups/service-groups.model.ts b/components/automate-ui/src/app/entities/service-groups/service-groups.model.ts
index 84598a7ab..138fcc84c 100644
--- a/components/automate-ui/src/app/entities/service-groups/service-groups.model.ts
+++ b/components/automate-ui/src/app/entities/service-groups/service-groups.model.ts
@@ -27,6 +27,16 @@ export interface ServiceGroupsFilters {
   sortDirection?: SortDirection;
 }
 
+export enum AllowedStatus {
+  OK = 'ok',
+  CRITICAL = 'critical',
+  WARNING = 'warning',
+  UNKNOWN = 'unknown',
+  TOTAL = 'total',
+  DISCONNECTED = 'disconnected',
+  CONNECTED = 'connected'
+}
+
 export interface ServiceGroupsHealthSummary {
   total: number;
   ok: number;
diff --git a/components/automate-ui/src/app/page-components/services-sidebar/services-sidebar.component.html b/components/automate-ui/src/app/page-components/services-sidebar/services-sidebar.component.html
index 6738f13b1..f800eab3a 100644
--- a/components/automate-ui/src/app/page-components/services-sidebar/services-sidebar.component.html
+++ b/components/automate-ui/src/app/page-components/services-sidebar/services-sidebar.component.html
@@ -21,39 +21,45 @@
   <div *ngIf="serviceGroupsId && (serviceGroupsStatus$ | async) === 'loadingSuccess'">
     <div class="status-filter-bar">
       <chef-status-filter-group [value]="selectedHealth" lean>
-        <chef-option class="filter general" value="total" (click)="updateHealthFilter('total')" (keyup.enter)="updateHealthFilter('total')" selected>
+        <chef-option class="filter general" value="total" (click)="updateHealthFilter(allowedStatus.TOTAL)" (keyup.enter)="updateHealthFilter(allowedStatus.TOTAL)" selected>
           <chef-icon class="filter-icon">grain</chef-icon>
           <div class="filter-label">Total</div>
           <div class="filter-total">{{ serviceGroupsHealthSummary.total }}</div>
         </chef-option>
-        <chef-option class="filter critical" value='critical' (click)="updateHealthFilter('critical')" (keyup.enter)="updateHealthFilter('critical')">
+        <chef-option class="filter critical" value='critical' (click)="updateHealthFilter(allowedStatus.CRITICAL)" (keyup.enter)="updateHealthFilter(allowedStatus.CRITICAL)">
           <chef-icon class="filter-icon">warning</chef-icon>
           <div class="filter-label">Critical</div>
           <div class="filter-total">{{ serviceGroupsHealthSummary.critical }}</div>
         </chef-option>
-        <chef-option class="filter warning" value='warning' (click)="updateHealthFilter('warning')" (keyup.enter)="updateHealthFilter('warning')">
+        <chef-option class="filter warning" value='warning' (click)="updateHealthFilter(allowedStatus.WARNING)" (keyup.enter)="updateHealthFilter(allowedStatus.WARNING)">
           <chef-icon class="filter-icon">error</chef-icon>
           <div class="filter-label">Warning</div>
           <div class="filter-total">{{ serviceGroupsHealthSummary.warning }}</div>
         </chef-option>
-        <chef-option class="filter disconnected" value='disconnected' (click)="updateHealthFilter('disconnected')" (keyup.enter)="updateHealthFilter('disconnected')">
-          <chef-icon class="filter-icon"></chef-icon>
-          <div class="filter-label">Disconnected</div>
-          <div class="filter-total">{{ serviceGroupsHealthSummary.disconnected }}</div>
-        </chef-option>
-        <chef-option class="filter success" value='ok' (click)="updateHealthFilter('ok')" (keyup.enter)="updateHealthFilter('ok')">
+        <chef-option class="filter success" value='ok' (click)="updateHealthFilter(allowedStatus.OK)" (keyup.enter)="updateHealthFilter(allowedStatus.OK)">
           <chef-icon class="filter-icon">check_circle</chef-icon>
           <div class="filter-label">OK</div>
           <div class="filter-total">{{ serviceGroupsHealthSummary.ok }}</div>
         </chef-option>
-        <chef-option class="filter unknown" value='unknown' (click)="updateHealthFilter('unknown')" (keyup.enter)="updateHealthFilter('unknown')">
+        <chef-option class="filter unknown" value='unknown' (click)="updateHealthFilter(allowedStatus.UNKNOWN)" (keyup.enter)="updateHealthFilter(allowedStatus.UNKNOWN)">
           <chef-icon class="filter-icon">help</chef-icon>
           <div class="filter-label">Unknown</div>
           <div class="filter-total">{{ serviceGroupsHealthSummary.unknown }}</div>
         </chef-option>
+        <div class="spacer"></div>
+        <chef-option class="filter connected" value='connected' (click)="updateHealthFilter(allowedStatus.CONNECTED)" (keyup.enter)="updateHealthFilter(allowedStatus.CONNECTED)">
+          <chef-icon class="filter-icon">link</chef-icon>
+          <div class="filter-label">Connected</div>
+          <div class="filter-total">{{ serviceGroupsHealthSummary.total - serviceGroupsHealthSummary.disconnected }}</div>
+        </chef-option>
+        <chef-option class="filter disconnected" value='disconnected' (click)="updateHealthFilter(allowedStatus.DISCONNECTED)" (keyup.enter)="updateHealthFilter(allowedStatus.DISCONNECTED)">
+          <chef-icon class="filter-icon"></chef-icon>
+          <div class="filter-label">Disconnected</div>
+          <div class="filter-total">{{ serviceGroupsHealthSummary.disconnected }}</div>
+        </chef-option>
       </chef-status-filter-group>
     </div>
-    <div class="bulk-action-bar">
+    <div class="bulk-action-bar" [class.hidden]="(services$ | async)?.length === 0" [attr.aria-hidden]="(services$ | async)?.length === 0">
       <chef-checkbox
         [checked]="hasAllSelected"
         [indeterminate]="isIndeterminate"
diff --git a/components/automate-ui/src/app/page-components/services-sidebar/services-sidebar.component.scss b/components/automate-ui/src/app/page-components/services-sidebar/services-sidebar.component.scss
index 41f08404c..2515390c2 100644
--- a/components/automate-ui/src/app/page-components/services-sidebar/services-sidebar.component.scss
+++ b/components/automate-ui/src/app/page-components/services-sidebar/services-sidebar.component.scss
@@ -26,6 +26,11 @@ chef-tooltip {
 
 chef-status-filter-group {
   padding-top: 5px;
+
+  .spacer {
+    width: 100%;
+    height: 0;
+  }
 }
 
 app-page-picker {
@@ -344,6 +349,10 @@ chef-alert {
   chef-button {
     margin: 0 8px;
   }
+
+  &.hidden {
+    visibility: hidden;
+  }
 }
 
 chef-modal[label="delete-services-modal"] {
diff --git a/components/automate-ui/src/app/page-components/services-sidebar/services-sidebar.component.ts b/components/automate-ui/src/app/page-components/services-sidebar/services-sidebar.component.ts
index e10c6a198..87b7745e4 100644
--- a/components/automate-ui/src/app/page-components/services-sidebar/services-sidebar.component.ts
+++ b/components/automate-ui/src/app/page-components/services-sidebar/services-sidebar.component.ts
@@ -11,7 +11,8 @@ import { ServiceGroupsFacadeService } from '../../entities/service-groups/servic
 import {
   ServiceGroupsHealthSummary,
   GroupService,
-  GroupServicesFilters
+  GroupServicesFilters,
+  AllowedStatus
 } from '../../entities/service-groups/service-groups.model';
 import { UpdateSelectedSG, DeleteServicesById } from 'app/entities/service-groups/service-groups.actions';
 import { TelemetryService } from 'app/services/telemetry/telemetry.service';
@@ -28,6 +29,7 @@ export class ServicesSidebarComponent implements OnInit, OnDestroy {
   @Input() serviceGroupsId: string;
   @Input() visible: boolean;
 
+  public allowedStatus = AllowedStatus;
   public selectedHealth = 'total';
   public currentPage = 1;
   public pageSize = 25;
@@ -121,7 +123,7 @@ export class ServicesSidebarComponent implements OnInit, OnDestroy {
     this.isDestroyed.complete();
   }
 
-  public updateHealthFilter(health: string) {
+  public updateHealthFilter(health: AllowedStatus) {
     this.currentPage = 1;
     this.selectedHealth = this.serviceGroupsFacade
       .updateHealthFilter(health, 'applicationsStatusFilter');
diff --git a/components/chef-ui-library/src/molecules/chef-status-filter-group/chef-status-filter-group.scss b/components/chef-ui-library/src/molecules/chef-status-filter-group/chef-status-filter-group.scss
index 07fe63408..3998fabbe 100644
--- a/components/chef-ui-library/src/molecules/chef-status-filter-group/chef-status-filter-group.scss
+++ b/components/chef-ui-library/src/molecules/chef-status-filter-group/chef-status-filter-group.scss
@@ -72,6 +72,13 @@ chef-status-filter-group {
       }
     }
 
+    &.connected {
+      chef-icon {
+        color: var(--chef-success);
+        transform: rotate(-45deg);
+      }
+    }
+
     &.disconnected {
       chef-icon {
         color: var(--chef-dark-grey);
@@ -109,7 +116,8 @@ chef-status-filter-group {
         box-shadow: 0px 0px 0px 1px var(--chef-primary) inset;
       }
 
-      &.success {
+      &.success,
+      &.connected {
         background: #CFE8FF;
         box-shadow: 0px 0px 0px 1px var(--chef-success) inset;
       }
@@ -140,7 +148,8 @@ chef-status-filter-group {
         background: #FFE5D4;
       }
 
-      &.success {
+      &.success,
+      &.connected {
         background: #CFE8FF;
       }
 
diff --git a/components/chef-ui-library/src/molecules/chef-status-filter-group/chef-status-filter-group.tsx b/components/chef-ui-library/src/molecules/chef-status-filter-group/chef-status-filter-group.tsx
index 421513f40..71ccc83aa 100644
--- a/components/chef-ui-library/src/molecules/chef-status-filter-group/chef-status-filter-group.tsx
+++ b/components/chef-ui-library/src/molecules/chef-status-filter-group/chef-status-filter-group.tsx
@@ -32,6 +32,11 @@ import find from 'lodash/fp/find';
  *     <div class="filter-label">Disconnected</div>
  *     <div class="filter-total">8</div>
  *   </chef-option>
+ *   <chef-option class="filter connected" value='connected'>
+ *     <chef-icon class="filter-icon">link</chef-icon>
+ *     <div class="filter-label">Connected</div>
+ *     <div class="filter-total">22</div>
+ *   </chef-option>
  *   <chef-option class="filter unknown" value='unknown'>
  *     <chef-icon class="filter-icon">help</chef-icon>
  *     <div class="filter-label">Unknown</div>
diff --git a/components/ingest-service/pipeline/publisher/msg_distributor.go b/components/ingest-service/pipeline/publisher/msg_distributor.go
index bd2a0a410..2ee65807d 100644
--- a/components/ingest-service/pipeline/publisher/msg_distributor.go
+++ b/components/ingest-service/pipeline/publisher/msg_distributor.go
@@ -54,7 +54,8 @@ func sendMessage(pipeInChannels []chan message.ChefRun, msg message.ChefRun) {
 
 		log.WithFields(log.Fields{
 			"number_of_publishers": len(pipeInChannels),
-		}).Warn("All elasticsearch publishers are full")
+		}).Error("All elasticsearch publishers are full. " +
+			"Increase the max number of bundled messages and/or number of publishers")
 		time.Sleep(time.Millisecond * 10)
 	}
 }
diff --git a/lib/config/resolve_config_parser.go b/lib/config/resolve_config_parser.go
new file mode 100644
index 000000000..03b129c2c
--- /dev/null
+++ b/lib/config/resolve_config_parser.go
@@ -0,0 +1,68 @@
+package config
+
+import (
+	"bytes"
+	"io/ioutil"
+	"net"
+	"regexp"
+)
+
+var (
+	defaultResolvConf = "/etc/resolv.conf"
+	nameServerRegex   = regexp.MustCompile(`^\s*nameserver\s*(.+?)\s*$`)
+)
+
+// GetNameServersFromResolveConfig parses a file in the format of /etc/resolv.conf
+// The resolve.conf format is described in resolv.conf(5):
+//
+//    https://man7.org/linux/man-pages/man5/resolv.conf.5.html
+//
+// This function looks up at the lines starting with `nameserver`.
+// There can be a single nameserver each line and maximum 3 (defined
+// my MAXNS in resolv.h) in the file.
+func GetNameServersFromResolveConfig(filePath string) ([]string, error) {
+	content, err := ioutil.ReadFile(filePath)
+	if err != nil {
+		return nil, err
+	}
+
+	nameservers := ParseNameServers(content)
+	return nameservers, nil
+}
+
+// ParseNameServers parses an array of bytes and returns the nameservers
+// The content should be of format of /etc/resolv.conf i.e.
+// nameserver 12.0.0.3
+func ParseNameServers(fileContent []byte) []string {
+	fileLines := bytes.Split(fileContent, []byte("\n"))
+	var nameservers []string
+	for _, currentLine := range fileLines {
+		var contentToParse = currentLine
+		var commentIndicatorIndex = bytes.Index(currentLine, []byte("#"))
+		if commentIndicatorIndex != -1 {
+			// Only check the content before the comment section
+			contentToParse = currentLine[:commentIndicatorIndex]
+		}
+
+		server := nameServerRegex.FindSubmatch(contentToParse)
+		if len(server) == 2 {
+			address := string(server[1])
+			if net.ParseIP(address) != nil {
+				nameservers = append(nameservers, address)
+			}
+		}
+	}
+
+	return nameservers
+}
+
+// GetSystemResolvers returns resolvers discovered via
+// /etc/resolv.conf. If not valid resolvers are found, an empty array
+// is returned.
+func GetSystemResolvers() []string {
+	resolvers, err := GetNameServersFromResolveConfig(defaultResolvConf)
+	if err != nil {
+		return []string{}
+	}
+	return resolvers
+}


Test patch:
diff --git a/lib/config/resolve_config_parser_test.go b/lib/config/resolve_config_parser_test.go
new file mode 100644
index 000000000..655ae9989
--- /dev/null
+++ b/lib/config/resolve_config_parser_test.go
@@ -0,0 +1,81 @@
+package config_test
+
+import (
+	"testing"
+
+	"github.com/chef/automate/lib/config"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestParseNameServers(t *testing.T) {
+	cases := []struct {
+		desc     string
+		content  []byte
+		expected []string
+	}{
+		{
+			desc: "parses IPv4 nameserver",
+			content: []byte(`
+nameserver 10.0.0.3
+	`),
+			expected: []string{"10.0.0.3"},
+		},
+		{
+			desc: "parses IPv6 nameserver",
+			content: []byte(`
+	nameserver 2001:0db8:85a3:0000:0000:8a2e:0370:7334
+	`),
+			expected: []string{"2001:0db8:85a3:0000:0000:8a2e:0370:7334"},
+		},
+		{
+			desc: "parses spaces between nameserver and IP",
+			content: []byte(`
+nameserver                          10.0.0.3
+	`),
+			expected: []string{"10.0.0.3"},
+		},
+		{
+			desc: "parses multiple nameservers",
+			content: []byte(`
+	nameserver 10.0.0.3
+	nameserver 10.0.0.4
+	`),
+			expected: []string{"10.0.0.3", "10.0.0.4"},
+		},
+		{
+			desc: "parses content with single-line comments",
+			content: []byte(`
+	# Here goes the name servers
+	nameserver 10.0.0.3
+	`),
+			expected: []string{"10.0.0.3"},
+		},
+		{
+			desc: "parses content with same-line comments",
+			content: []byte(`
+	nameserver 10.0.0.3 # Here goes the name servers
+	`),
+			expected: []string{"10.0.0.3"},
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.desc, func(t *testing.T) {
+			ns := config.ParseNameServers(c.content)
+			assert.Equal(t, c.expected, ns)
+
+		})
+	}
+}
+
+func TestGetNameServersFromResolveConfig(t *testing.T) {
+	var filePath = "./testdata/sample_resolv_conf"
+	ns, err := config.GetNameServersFromResolveConfig(filePath)
+	require.NoError(t, err, "failed to parse config")
+
+	var expectedResult = []string{"255.0.0.1"}
+
+	require.Equal(t, expectedResult, ns)
+}
diff --git a/lib/config/testdata/sample_resolv_conf b/lib/config/testdata/sample_resolv_conf
new file mode 100644
index 000000000..d2ac5fd1d
--- /dev/null
+++ b/lib/config/testdata/sample_resolv_conf
@@ -0,0 +1,19 @@
+# This file is managed by man:systemd-resolved(8). Do not edit.
+#
+# This is a dynamic resolv.conf file for connecting local clients to the
+# internal DNS stub resolver of systemd-resolved. This file lists all
+# configured search domains.
+#
+# Run "resolvectl status" to see details about the uplink DNS servers
+# currently in use.
+#
+# Third party programs must not access this file directly, but only through the
+# symlink at /etc/resolv.conf. To manage man:resolv.conf(5) in a different way,
+# replace this symlink by a static file or a different symlink.
+#
+# See man:systemd-resolved.service(8) for details about the supported modes of
+# operation for /etc/resolv.conf.
+
+nameserver 255.0.0.1
+options edns0
+search us-west-2.compute.internal
\ No newline at end of file