Tenant health info upload (#2606)

For registered clusters, after generating the Health Info report, Health Info is uploaded to Subnet, and latest metrics are visible in Subnet.
Authored-by: Jillian Inapurapu <jillii@Jillians-MBP.attlocal.net>
This commit is contained in:
jinapurapu
2023-03-17 09:42:01 -07:00
committed by GitHub
parent 8b1b2b1e2d
commit c9ac525358
7 changed files with 174 additions and 40 deletions

View File

@@ -18,11 +18,13 @@ package subnet
import ( import (
"bytes" "bytes"
"compress/gzip"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"io/ioutil" "io/ioutil"
"mime/multipart"
"net/http" "net/http"
xhttp "github.com/minio/console/pkg/http" xhttp "github.com/minio/console/pkg/http"
@@ -64,6 +66,62 @@ func LogWebhookURL() string {
return subnetBaseURL() + "/api/logs" return subnetBaseURL() + "/api/logs"
} }
func UploadURL(uploadType string, filename string) string {
return fmt.Sprintf("%s/api/%s/upload?filename=%s", subnetBaseURL(), uploadType, filename)
}
func UploadAuthHeaders(apiKey string) map[string]string {
return map[string]string{"x-subnet-api-key": apiKey}
}
func UploadFileToSubnet(info interface{}, client *xhttp.Client, filename string, reqURL string, headers map[string]string) (string, error) {
req, e := subnetUploadReq(info, reqURL, filename)
if e != nil {
return "", e
}
resp, e := subnetReqDo(client, req, headers)
return resp, e
}
func subnetUploadReq(info interface{}, url string, filename string) (*http.Request, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
zipWriter := gzip.NewWriter(&body)
version := "3"
enc := json.NewEncoder(zipWriter)
header := struct {
Version string `json:"version"`
}{Version: version}
if e := enc.Encode(header); e != nil {
return nil, e
}
if e := enc.Encode(info); e != nil {
return nil, e
}
zipWriter.Close()
temp := body
part, e := writer.CreateFormFile("file", filename)
if e != nil {
return nil, e
}
if _, e = io.Copy(part, &temp); e != nil {
return nil, e
}
writer.Close()
r, e := http.NewRequest(http.MethodPost, url, &body)
if e != nil {
return nil, e
}
r.Header.Add("Content-Type", writer.FormDataContentType())
return r, nil
}
func GenerateRegToken(clusterRegInfo mc.ClusterRegistrationInfo) (string, error) { func GenerateRegToken(clusterRegInfo mc.ClusterRegistrationInfo) (string, error) {
token, e := json.Marshal(clusterRegInfo) token, e := json.Marshal(clusterRegInfo)
if e != nil { if e != nil {

View File

@@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
import React, { Fragment, useEffect, useState } from "react"; import React, { Fragment, useEffect, useState } from "react";
import clsx from "clsx";
import { import {
ICloseEvent, ICloseEvent,
IMessageEvent, IMessageEvent,
@@ -72,7 +72,7 @@ const styles = (theme: Theme) =>
color: "#07193E", color: "#07193E",
fontWeight: "bold", fontWeight: "bold",
textAlign: "center", textAlign: "center",
marginBottom: 10, marginBottom: 20,
}, },
progressResult: { progressResult: {
textAlign: "center", textAlign: "center",
@@ -94,8 +94,6 @@ const styles = (theme: Theme) =>
interface IHealthInfo { interface IHealthInfo {
classes: any; classes: any;
namespace: string;
tenant: string;
} }
const HealthInfo = ({ classes }: IHealthInfo) => { const HealthInfo = ({ classes }: IHealthInfo) => {
@@ -104,22 +102,19 @@ const HealthInfo = ({ classes }: IHealthInfo) => {
const message = useSelector((state: AppState) => state.healthInfo.message); const message = useSelector((state: AppState) => state.healthInfo.message);
const clusterRegistered = registeredCluster();
const serverDiagnosticStatus = useSelector( const serverDiagnosticStatus = useSelector(
(state: AppState) => state.system.serverDiagnosticStatus (state: AppState) => state.system.serverDiagnosticStatus
); );
const [startDiagnostic, setStartDiagnostic] = useState(false); const [startDiagnostic, setStartDiagnostic] = useState(false);
const [downloadDisabled, setDownloadDisabled] = useState(true); const [downloadDisabled, setDownloadDisabled] = useState(true);
const [localMessage, setMessage] = useState<string>(""); const [localMessage, setMessage] = useState<string>("");
const [buttonStartText, setButtonStartText] = const [buttonStartText, setButtonStartText] =
useState<string>("Start Diagnostic"); useState<string>("Start Diagnostic");
const [title, setTitle] = useState<string>("New Diagnostic"); const [title, setTitle] = useState<string>("New Diagnostic");
const [diagFileContent, setDiagFileContent] = useState<string>(""); const [diagFileContent, setDiagFileContent] = useState<string>("");
const [subnetResponse, setSubnetResponse] = useState<string>("");
const isDiagnosticComplete = const clusterRegistered = registeredCluster();
serverDiagnosticStatus === DiagStatSuccess ||
serverDiagnosticStatus === DiagStatError;
const download = () => { const download = () => {
let element = document.createElement("a"); let element = document.createElement("a");
@@ -195,7 +190,6 @@ const HealthInfo = ({ classes }: IHealthInfo) => {
const c = new W3CWebSocket( const c = new W3CWebSocket(
`${wsProt}://${url.hostname}:${port}${baseUrl}ws/health-info?deadline=1h` `${wsProt}://${url.hostname}:${port}${baseUrl}ws/health-info?deadline=1h`
); );
let interval: any | null = null; let interval: any | null = null;
if (c !== null) { if (c !== null) {
c.onopen = () => { c.onopen = () => {
@@ -220,6 +214,9 @@ const HealthInfo = ({ classes }: IHealthInfo) => {
if (m.encoded !== "") { if (m.encoded !== "") {
setDiagFileContent(m.encoded); setDiagFileContent(m.encoded);
} }
if (m.subnetResponse) {
setSubnetResponse(m.subnetResponse);
}
}; };
c.onerror = (error: Error) => { c.onerror = (error: Error) => {
console.log("error closing websocket:", error.message); console.log("error closing websocket:", error.message);
@@ -275,38 +272,70 @@ const HealthInfo = ({ classes }: IHealthInfo) => {
className={classes.progressResult} className={classes.progressResult}
> >
<div className={classes.localMessage}>{localMessage}</div> <div className={classes.localMessage}>{localMessage}</div>
<div className={classes.progressResult}>
{" "}
{subnetResponse !== "" &&
!subnetResponse.toLowerCase().includes("error") && (
<Grid item xs={12} className={classes.serversData}>
<strong>
Health report uploaded to Subnet successfully!
</strong>
&nbsp;{" "}
<strong>
See the results on your{" "}
<a href={subnetResponse}>Subnet Dashboard</a>{" "}
</strong>
</Grid>
)}
{(subnetResponse === "" ||
subnetResponse.toLowerCase().includes("error")) &&
serverDiagnosticStatus === DiagStatSuccess && (
<Grid item xs={12} className={classes.serversData}>
<strong>
Something went wrong uploading your Health report to
Subnet.
</strong>
&nbsp;{" "}
<strong>
Log into your{" "}
<a href="https://subnet.min.io">Subnet Account</a> to
manually upload your Health report.
</strong>
</Grid>
)}
</div>
{serverDiagnosticStatus === DiagStatInProgress ? ( {serverDiagnosticStatus === DiagStatInProgress ? (
<div className={classes.loading}> <div className={classes.loading}>
<Loader style={{ width: 25, height: 25 }} /> <Loader style={{ width: 25, height: 25 }} />
</div> </div>
) : ( ) : (
<Fragment> <Fragment>
{serverDiagnosticStatus !== DiagStatError && <Grid container justifyItems={"flex-start"}>
!downloadDisabled && ( <Grid item xs={6}>
{serverDiagnosticStatus !== DiagStatError &&
!downloadDisabled && (
<Button
id={"download"}
type="submit"
variant="callAction"
onClick={() => download()}
disabled={downloadDisabled}
label={"Download"}
/>
)}
</Grid>
<Grid item xs={6}>
<Button <Button
id={"download"} id="start-new-diagnostic"
type="submit" type="submit"
variant="callAction" variant={
onClick={() => download()} !clusterRegistered ? "regular" : "callAction"
disabled={downloadDisabled} }
label={"Download"} disabled={startDiagnostic}
onClick={startDiagnosticAction}
label={buttonStartText}
/> />
)} </Grid>
<Grid
item
xs={12}
className={clsx(classes.startDiagnostic, {
[classes.startDiagnosticCenter]: !isDiagnosticComplete,
})}
>
<Button
id="start-new-diagnostic"
type="submit"
variant={!clusterRegistered ? "regular" : "callAction"}
disabled={startDiagnostic}
onClick={startDiagnosticAction}
label={buttonStartText}
/>
</Grid> </Grid>
</Fragment> </Fragment>
)} )}
@@ -322,7 +351,12 @@ const HealthInfo = ({ classes }: IHealthInfo) => {
"During the health diagnostics run, all production traffic will be suspended." "During the health diagnostics run, all production traffic will be suspended."
} }
iconComponent={<WarnIcon />} iconComponent={<WarnIcon />}
help={<Fragment />} help={
<Fragment>
Cluster Health Report will be uploaded to Subnet, and is
viewable from your Subnet Diagnostics dashboard.
</Fragment>
}
/> />
</Fragment> </Fragment>
)} )}

View File

@@ -29,6 +29,7 @@ export interface HealthInfoMessage {
export interface ReportMessage { export interface ReportMessage {
encoded: string; encoded: string;
serverHealthInfo: HealthInfoMessage; serverHealthInfo: HealthInfoMessage;
subnetResponse: string;
} }
export interface perfInfo { export interface perfInfo {

View File

@@ -147,7 +147,7 @@ export const systemSlice = createSlice({
setSiteReplicationInfo: (state, action: PayloadAction<SRInfoStateType>) => { setSiteReplicationInfo: (state, action: PayloadAction<SRInfoStateType>) => {
state.siteReplicationInfo = action.payload; state.siteReplicationInfo = action.payload;
}, },
setLicenseInfo: (state, action: PayloadAction<SubnetInfo | null>) => { setSystemLicenseInfo: (state, action: PayloadAction<SubnetInfo | null>) => {
state.licenseInfo = action.payload; state.licenseInfo = action.payload;
}, },
setOverrideStyles: ( setOverrideStyles: (
@@ -181,7 +181,7 @@ export const {
setServerDiagStat, setServerDiagStat,
globalSetDistributedSetup, globalSetDistributedSetup,
setSiteReplicationInfo, setSiteReplicationInfo,
setLicenseInfo, setSystemLicenseInfo,
setOverrideStyles, setOverrideStyles,
setAnonymousMode, setAnonymousMode,
resetSystem, resetSystem,

View File

@@ -22,11 +22,14 @@ import (
b64 "encoding/base64" b64 "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/klauspost/compress/gzip" "github.com/klauspost/compress/gzip"
xhttp "github.com/minio/console/pkg/http"
subnet "github.com/minio/console/pkg/subnet"
"github.com/minio/madmin-go/v2" "github.com/minio/madmin-go/v2"
"github.com/minio/websocket" "github.com/minio/websocket"
) )
@@ -51,7 +54,6 @@ func startHealthInfo(ctx context.Context, conn WSConn, client MinioAdmin, deadli
madmin.HealthDataTypeSysNet, madmin.HealthDataTypeSysNet,
madmin.HealthDataTypeSysProcess, madmin.HealthDataTypeSysProcess,
} }
var err error var err error
// Fetch info of all servers (cluster or single server) // Fetch info of all servers (cluster or single server)
healthInfo, version, err := client.serverHealthInfo(ctx, healthDataTypes, *deadline) healthInfo, version, err := client.serverHealthInfo(ctx, healthDataTypes, *deadline)
@@ -68,12 +70,19 @@ func startHealthInfo(ctx context.Context, conn WSConn, client MinioAdmin, deadli
type messageReport struct { type messageReport struct {
Encoded string `json:"encoded"` Encoded string `json:"encoded"`
ServerHealthInfo interface{} `json:"serverHealthInfo"` ServerHealthInfo interface{} `json:"serverHealthInfo"`
SubnetResponse string `json:"subnetResponse"`
} }
subnetResp, err := sendHealthInfoToSubnet(ctx, healthInfo, client)
report := messageReport{ report := messageReport{
Encoded: encodedDiag, Encoded: encodedDiag,
ServerHealthInfo: healthInfo, ServerHealthInfo: healthInfo,
SubnetResponse: subnetResp,
} }
if err != nil {
report.SubnetResponse = fmt.Sprintf("Error: %s", err.Error())
}
message, err := json.Marshal(report) message, err := json.Marshal(report)
if err != nil { if err != nil {
return err return err
@@ -117,3 +126,35 @@ func getHealthInfoOptionsFromReq(req *http.Request) (*time.Duration, error) {
} }
return &deadlineDuration, nil return &deadlineDuration, nil
} }
func sendHealthInfoToSubnet(ctx context.Context, healthInfo interface{}, client MinioAdmin) (string, error) {
filename := fmt.Sprintf("health_%d.json", time.Now().Unix())
subnetUploadURL := subnet.UploadURL("health", filename)
subnetHTTPClient := &xhttp.Client{Client: GetConsoleHTTPClient("")}
subnetTokenConfig, e := GetSubnetKeyFromMinIOConfig(ctx, client)
if e != nil {
return "", e
}
apiKey := subnetTokenConfig.APIKey
headers := subnet.UploadAuthHeaders(apiKey)
resp, e := subnet.UploadFileToSubnet(healthInfo, subnetHTTPClient, filename, subnetUploadURL, headers)
if e != nil {
return "", e
}
type SubnetResponse struct {
ClusterURL string `json:"cluster_url,omitempty"`
}
var subnetResp SubnetResponse
e = json.Unmarshal([]byte(resp), &subnetResp)
if e != nil {
return "", e
}
if len(subnetResp.ClusterURL) != 0 {
subnetClusterURL := strings.ReplaceAll(subnetResp.ClusterURL, "%2f", "/")
return subnetClusterURL, nil
}
return "", ErrSubnetUploadFail
}

View File

@@ -71,6 +71,7 @@ var (
ErrEncryptionConfigNotFound = errors.New("encryption configuration not found") ErrEncryptionConfigNotFound = errors.New("encryption configuration not found")
ErrPolicyNotFound = errors.New("policy does not exist") ErrPolicyNotFound = errors.New("policy does not exist")
ErrLoginNotAllowed = errors.New("login not allowed") ErrLoginNotAllowed = errors.New("login not allowed")
ErrSubnetUploadFail = errors.New("Subnet upload failed")
) )
// ErrorWithContext : // ErrorWithContext :

View File

@@ -502,7 +502,6 @@ func (wsc *wsAdminClient) healthInfo(ctx context.Context, deadline *time.Duratio
LogInfo("health info started") LogInfo("health info started")
ctx = wsReadClientCtx(ctx, wsc.conn) ctx = wsReadClientCtx(ctx, wsc.conn)
err := startHealthInfo(ctx, wsc.conn, wsc.client, deadline) err := startHealthInfo(ctx, wsc.conn, wsc.client, deadline)
sendWsCloseMessage(wsc.conn, err) sendWsCloseMessage(wsc.conn, err)