Matthew Fuller 587031c09f Task viewer for airshipui
This change introduces a task viewer component to the UI
which will allow users to monitor the progress of long
running tasks without having to stay on a particular tab.
Tasks are created and attached to CTL event processors and
injected into phase clients so that status message updates
can be displayed dynamically.

Still TODO at some point is utilizing backend caching to tie
tasks to the users who initiated them so that browser refreshes
(i.e. new session IDs) won't empty the task viewer for that user.

Change-Id: I38aa03d2660d1fcc2bad6ecda718015602e25b6a
2020-10-23 18:29:37 +00:00

558 lines
12 KiB
Go

/*
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package ctl
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"time"
"opendev.org/airship/airshipui/pkg/webservice"
"github.com/google/uuid"
"opendev.org/airship/airshipctl/pkg/document"
"opendev.org/airship/airshipctl/pkg/events"
"opendev.org/airship/airshipctl/pkg/phase"
"opendev.org/airship/airshipctl/pkg/phase/ifc"
"opendev.org/airship/airshipui/pkg/configs"
"opendev.org/airship/airshipui/pkg/task"
)
var (
fileIndex map[string]string
docIndex map[string]document.Document
)
// HandlePhaseRequest will flop between requests so we don't have to have them all mapped as function calls
// This will wait for the sub component to complete before responding. The assumption is this is an async request
func HandlePhaseRequest(user *string, request configs.WsMessage) configs.WsMessage {
id := request.ID
response := configs.WsMessage{
Type: configs.CTL,
Component: configs.Phase,
SubComponent: request.SubComponent,
ID: id,
}
var err error
var message *string
var valid bool
client, err := NewClient(AirshipConfigPath, KubeConfigPath, request)
if err != nil {
e := err.Error()
response.Error = &e
return response
}
switch request.SubComponent {
case configs.Run:
err = client.RunPhase(request)
case configs.ValidatePhase:
valid, err = client.ValidatePhase(request.ID, request.SessionID)
message = validateHelper(valid)
case configs.YamlWrite:
response.Name, response.YAML, err = client.writeYamlFile(id, request.YAML)
s := fmt.Sprintf("File '%s' saved successfully", response.Name)
message = &s
case configs.GetYaml:
message = request.Message
response.Name, response.YAML, err = client.getYaml(id, *message)
case configs.GetPhaseTree:
response.Data, err = client.GetPhaseTree()
case configs.GetPhase:
s := "rendered"
message = &s
response.Name, response.Details, response.YAML, err = client.GetPhase(id)
case configs.GetDocumentsBySelector:
message = request.Message
response.Data, err = GetDocumentsBySelector(request.ID, *message)
case configs.GetTarget:
message = client.getTarget()
case configs.GetExecutorDoc:
s := "rendered"
message = &s
response.Name, response.YAML, err = client.GetExecutorDoc(id)
default:
err = fmt.Errorf("Subcomponent %s not found", request.SubComponent)
}
if err != nil {
e := err.Error()
response.Error = &e
} else {
response.Message = message
}
return response
}
// this helper function will likely disappear once a clear workflow for
// phase validation takes shape in UI. For now, it simply returns a
// string message to be displayed as a toast in frontend client
func validateHelper(valid bool) *string {
msg := "invalid"
if valid {
msg = "valid"
}
return &msg
}
// GetExecutorDoc returns the title and YAML of the executor document for the specified phase
func (c *Client) GetExecutorDoc(id string) (string, string, error) {
helper, err := getHelper()
if err != nil {
return "", "", err
}
phaseID := ifc.ID{}
err = json.Unmarshal([]byte(id), &phaseID)
if err != nil {
return "", "", err
}
ed, err := helper.ExecutorDoc(phaseID)
if err != nil {
return "", "", err
}
title := ed.GetName()
bytes, err := ed.AsYAML()
if err != nil {
return "", "", err
}
return title, base64.StdEncoding.EncodeToString(bytes), nil
}
func (c *Client) getTarget() *string {
var s string
m, err := c.Config.CurrentContextManifest()
if err != nil {
s = "unknown"
return &s
}
s = filepath.Join(m.TargetPath, m.SubPath)
return &s
}
func (c *Client) getPhaseDetails(id ifc.ID) (string, error) {
helper, err := getHelper()
if err != nil {
return "", err
}
pClient := phase.NewClient(helper)
phase, err := pClient.PhaseByID(id)
if err != nil {
return "", err
}
return phase.Details()
}
func (c *Client) getYaml(id, message string) (string, string, error) {
switch message {
case "source":
name, yaml, err := c.getFileYaml(id)
return name, yaml, err
case "rendered":
name, yaml, err := c.getDocumentYaml(id)
return name, yaml, err
default:
return "", "", fmt.Errorf("'%s' unrecognized document type", message)
}
}
func (c *Client) getDocumentYaml(id string) (string, string, error) {
doc, ok := docIndex[id]
if !ok {
return "", "", fmt.Errorf("document with ID '%s' not found", id)
}
title := doc.GetName()
bytes, err := doc.AsYAML()
if err != nil {
return "", "", err
}
return title, base64.StdEncoding.EncodeToString(bytes), nil
}
func (c *Client) getFileYaml(id string) (string, string, error) {
path, ok := fileIndex[id]
if !ok {
return "", "", fmt.Errorf("file with ID '%s' not found", id)
}
ccm, err := c.Config.CurrentContextManifest()
if err != nil {
return "", "", err
}
// this is making the assumption that the site definition
// will always found at: targetPath/subPath
sitePath := filepath.Join(ccm.TargetPath, ccm.SubPath)
// TODO(mfuller): will this be true in treasuremap or
// other external repos?
manifestsDir := filepath.Join(sitePath, "..", "..")
title, err := filepath.Rel(manifestsDir, path)
if err != nil {
return "", "", err
}
file, err := os.Open(path)
if err != nil {
return "", "", err
}
defer file.Close()
bytes, err := ioutil.ReadAll(file)
if err != nil {
return "", "", err
}
return title, base64.StdEncoding.EncodeToString(bytes), nil
}
func (c *Client) writeYamlFile(id, yaml64 string) (string, string, error) {
path, ok := fileIndex[id]
if !ok {
return "", "", fmt.Errorf("ID %s not found", id)
}
yaml, err := base64.StdEncoding.DecodeString(yaml64)
if err != nil {
return "", "", err
}
err = ioutil.WriteFile(path, yaml, 0600)
if err != nil {
return "", "", err
}
return c.getFileYaml(id)
}
func getPhaseBundle(id ifc.ID) (document.Bundle, error) {
helper, err := getHelper()
if err != nil {
return nil, err
}
pClient := phase.NewClient(helper)
phase, err := pClient.PhaseByID(id)
if err != nil {
return nil, err
}
docRoot, err := phase.DocumentRoot()
if err != nil {
// if phase has no doc entrypoint defined, just
// return nothing; otherwise, return the error
if strings.Contains(err.Error(), "defined") {
return nil, nil
}
return nil, err
}
b, err := document.NewBundleByPath(docRoot)
if err != nil {
return nil, err
}
return b, nil
}
// GetPhase returns the name, description, and doc bundle for specified phase
func (c *Client) GetPhase(id string) (string, string, string, error) {
phaseID := ifc.ID{}
err := json.Unmarshal([]byte(id), &phaseID)
if err != nil {
return "", "", "", err
}
title := phaseID.Name
details, err := c.getPhaseDetails(phaseID)
if err != nil {
return "", "", "", err
}
bundle, err := getPhaseBundle(phaseID)
if err != nil {
return "", "", "", err
}
// only return title if phase has no bundle
if bundle == nil {
return title, details, "", nil
}
var buf bytes.Buffer
err = bundle.Write(&buf)
if err != nil {
return "", "", "", err
}
return title, details, base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
// SelectorParams structure to hold data for constructing a document Selector
type SelectorParams struct {
Name string `json:"name,omitempty"`
Namespace string `json:"namespace,omitempty"`
GVK GVK `json:"gvk,omitempty"`
Kind string `json:"kind,omitempty"`
Label string `json:"label,omitempty"`
Annotation string `json:"annotation,omitempty"`
}
// GVK small structure to hold group, version, kind for building a Selector
type GVK struct {
Group string `json:"group"`
Version string `json:"version"`
Kind string `json:"kind"`
}
// GetDocumentsBySelector returns a slice of KustomNodes representing all phase
// documents returned by applying the provided Selector
func GetDocumentsBySelector(id string, data string) ([]KustomNode, error) {
docIndex = map[string]document.Document{}
selector, err := getSelector(data)
if err != nil {
return nil, err
}
phaseID := ifc.ID{}
err = json.Unmarshal([]byte(id), &phaseID)
if err != nil {
return nil, err
}
helper, err := getHelper()
if err != nil {
return nil, err
}
pClient := phase.NewClient(helper)
phase, err := pClient.PhaseByID(phaseID)
if err != nil {
return nil, err
}
docRoot, err := phase.DocumentRoot()
if err != nil {
return nil, err
}
bundle, err := document.NewBundleByPath(docRoot)
if err != nil {
return nil, err
}
docs, err := bundle.Select(selector)
if err != nil {
return nil, err
}
results := []KustomNode{}
for _, doc := range docs {
// this is a workaround for a kustomize issue where cluster-scoped objects
// are included in matching results when a namespace selector is specified
// (https://github.com/kubernetes-sigs/kustomize/issues/2248)
if selector.Namespace != "" && selector.Namespace != doc.GetNamespace() {
continue
}
id := uuid.New().String()
docIndex[id] = doc
name := doc.GetNamespace()
if name == "" {
name = "[none]"
}
results = append(results, KustomNode{
ID: id,
Name: fmt.Sprintf("%s/%s/%s",
name,
doc.GetKind(),
doc.GetName(),
)},
)
}
return results, nil
}
func getSelector(data string) (document.Selector, error) {
params := SelectorParams{}
err := json.Unmarshal([]byte(data), &params)
if err != nil {
return document.Selector{}, err
}
s := document.NewSelector()
// build selector based on what we were given
if params.Name != "" {
s = s.ByName(params.Name)
}
if params.Namespace != "" {
s = s.ByNamespace(params.Namespace)
}
if (GVK{}) != params.GVK {
s = s.ByGvk(
params.GVK.Group,
params.GVK.Version,
params.GVK.Kind,
)
}
if params.Kind != "" {
s = s.ByKind(params.Kind)
}
if params.Label != "" {
s = s.ByLabel(params.Label)
}
if params.Annotation != "" {
s = s.ByAnnotation(params.Annotation)
}
return s, nil
}
// ValidatePhase validates the specified phase
// (ifc.Phase.Validate isn't implemented yet, so this function
// currently always returns "valid")
func (c *Client) ValidatePhase(id, sessionID string) (bool, error) {
phaseID := ifc.ID{}
err := json.Unmarshal([]byte(id), &phaseID)
if err != nil {
return false, err
}
// probably not needed for validate, but let's create one anyway
taskid := uuid.New().String()
phaseIfc, err := getPhaseIfc(phaseID, taskid, sessionID)
if err != nil {
return false, err
}
err = phaseIfc.Validate()
if err != nil {
return false, err
}
return true, nil
}
// RunPhase runs the selected phase
func (c *Client) RunPhase(request configs.WsMessage) error {
phaseID := ifc.ID{}
err := json.Unmarshal([]byte(request.ID), &phaseID)
if err != nil {
return err
}
name := phaseID.Name
taskID := uuid.New().String()
phaseIfc, err := getPhaseIfc(phaseID, taskID, request.SessionID)
if err != nil {
return err
}
opts := ifc.RunOptions{}
var bytes []byte
if request.Data != nil {
bytes, err = json.Marshal(request.Data)
if err != nil {
return err
}
err = json.Unmarshal(bytes, &opts)
if err != nil {
return err
}
}
// send initial TaskStart message to create task on frontend
msg := configs.WsMessage{
SessionID: request.SessionID,
Type: configs.UI,
Component: configs.Task,
SubComponent: configs.TaskStart,
Name: name,
ID: taskID,
Data: task.Progress{
StartTime: time.Now().UnixNano() / 1000000,
Message: fmt.Sprintf("Starting task '%s'", name),
Errors: []string{},
},
}
err = webservice.WebSocketSend(msg)
if err != nil {
return err
}
return phaseIfc.Run(opts)
}
// helper function to return a Phase interface based on a JSON
// string representation of an ifc.ID value
func getPhaseIfc(phaseID ifc.ID, taskID, sessionID string) (ifc.Phase, error) {
helper, err := getHelper()
if err != nil {
return nil, err
}
tsk := task.NewTask(sessionID, taskID, phaseID.Name)
var procFunc phase.ProcessorFunc
procFunc = func() events.EventProcessor {
return NewUIEventProcessor(sessionID, tsk)
}
// inject event processor to phase client
proc := phase.InjectProcessor(procFunc)
client := phase.NewClient(helper, proc)
phase, err := client.PhaseByID(phaseID)
if err != nil {
return nil, err
}
return phase, nil
}