Skip to content

Commit

Permalink
feat: add capability to choose params in suspend node.Fixes #8425 (#8472
Browse files Browse the repository at this point in the history
)

Signed-off-by: bjenuhb <Basanth_JenuHB@intuit.com>
  • Loading branch information
basanthjenuhb authored and alexec committed Apr 29, 2022
1 parent 32b1b3a commit 02fb874
Show file tree
Hide file tree
Showing 7 changed files with 353 additions and 3 deletions.
135 changes: 135 additions & 0 deletions docs/intermediate-inputs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Intermediate Parameters

> v3.3 and after
Traditionally, Argo workflows has supported input parameters from UI only when the workflow starts,
and after that, it's pretty much on auto pilot. But, there are a lot of use cases where human interaction is required.

This interaction is in the form of providing input text in the middle of the workflow, choosing from a dropdown of the options which a workflow step itself is intelligently generating.

A similar feature which you can see in jenkins is [pipeline-input-step](https://www.jenkins.io/doc/pipeline/steps/pipeline-input-step/)

Example use cases include:
- A human approval before doing something in production environment.
- Programatic generation of a list of inputs from which the user chooses.
Choosing from a list of available databases which the workflow itself is generating.

This feature is achieved via `suspend template`.

The workflow will pause at a `Suspend` node, and user will be able to update parameters using fields type text or dropdown.

## Intermediate Parameters Approval Example

- The below example shows static enum values `approval` step.
- The user will be able to choose between `[YES, NO]` which will be used in subsequent steps.

[![Approval Example Demo](https://img.youtube.com/vi/eyeZ2oddwWE/0.jpg)](https://youtu.be/eyeZ2oddwWE)

```yaml

entrypoint: cicd-pipeline
templates:
- name: cicd-pipeline
steps:
- - name: deploy-pre-prod
template: deploy
- - name: approval
template: approval
- - name: deploy-prod
template: deploy
when: '{{steps.approval.outputs.parameters.approve}} == YES'
- name: approval
suspend: {}
inputs:
parameters:
- name: approve
default: 'NO'
enum:
- 'YES'
- 'NO'
outputs:
parameters:
- name: approve
valueFrom:
supplied: {}
- name: deploy
container:
image: 'argoproj/argosay:v2'
command:
- /argosay
args:
- echo
- deploying
```
## Intermediate Parameters DB Schema Update Example
- The below example shows programatic generation of `enum` values.
- The `generate-db-list` template generates an output called `db_list`.
- This output is of type `json`.
- Since this `json` has a `key` called `enum`, with an array of options, the UI will parse this and display it as a dropdown.
- The output can be any string also, in which case the UI will display it as a text field. Which the user can later edit.

[![DB Schema Update Example Demo](https://img.youtube.com/vi/QgE-1782YJc/0.jpg)](https://youtu.be/QgE-1782YJc)

```yaml
entrypoint: db-schema-update
templates:
- name: db-schema-update
steps:
- - name: generate-db-list
template: generate-db-list
- - name: choose-db
template: choose-db
arguments:
parameters:
- name: db_name
value: '{{steps.generate-db-list.outputs.parameters.db_list}}'
- - name: update-schema
template: update-schema
arguments:
parameters:
- name: db_name
value: '{{steps.choose-db.outputs.parameters.db_name}}'
- name: generate-db-list
outputs:
parameters:
- name: db_list
valueFrom:
path: /tmp/db_list.txt
container:
name: main
image: 'argoproj/argosay:v2'
command:
- sh
- '-c'
args:
- >-
echo "{\"enum\": [\"db1\", \"db2\", \"db3\"]}" | tee /tmp/db_list.txt
- name: choose-db
inputs:
parameters:
- name: db_name
outputs:
parameters:
- name: db_name
valueFrom:
supplied: {}
suspend: {}
- name: update-schema
inputs:
parameters:
- name: db_name
container:
name: main
image: 'argoproj/argosay:v2'
command:
- sh
- '-c'
args:
- echo Updating DB {{inputs.parameters.db_name}}
```

### Some Important Details
- The suspend node should have the **SAME** parameters defined in `inputs.parameters` and `outputs.parameters`.
- All the output parameters in the suspended node should have `valueFrom.supplied: {}`
- The selected values will be available at `<SUSPENDED_NODE>.outputs.parameters.<PARAMETER_NAME>`
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ nav:
- artifact-repository-ref.md
- key-only-artifacts.md
- conditional-artifacts-parameters.md
- intermediate-inputs.md
- resource-duration.md
- estimated-duration.md
- workflow-pod-security-context.md
Expand Down
7 changes: 7 additions & 0 deletions ui/src/app/shared/services/workflows-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ export class WorkflowsService {
return requests.put(`api/v1/workflows/${namespace}/${name}/suspend`).then(res => res.body as Workflow);
}

public set(name: string, namespace: string, nodeFieldSelector: string, outputParameters: string) {
return requests
.put(`api/v1/workflows/${namespace}/${name}/set`)
.send({nodeFieldSelector, outputParameters})
.then(res => res.body as Workflow);
}

public resume(name: string, namespace: string, nodeFieldSelector: string) {
return requests
.put(`api/v1/workflows/${namespace}/${name}/resume`)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {Select} from 'argo-ui';
import * as React from 'react';
import {Parameter} from '../../../../models';

interface SuspendInputProps {
parameters: Parameter[];
nodeId: string;
setParameter: (key: string, value: string) => void;
}

export const SuspendInputs = (props: SuspendInputProps) => {
const [parameters, setParameters] = React.useState(props.parameters);

const setParameter = (key: string, value: string) => {
props.setParameter(key, value);
setParameters(previous => {
return previous.map(param => {
if (param.name === key) {
param.value = value;
}
return param;
});
});
};

const renderSelectField = (parameter: Parameter) => {
return (
<React.Fragment key={parameter.name}>
<br />
<label>{parameter.name}</label>
<Select
value={parameter.value || parameter.default}
options={parameter.enum.map(value => ({
value,
title: value
}))}
onChange={selected => setParameter(parameter.name, selected.value)}
/>
</React.Fragment>
);
};

const renderInputField = (parameter: Parameter) => {
return (
<React.Fragment key={parameter.name}>
<br />
<label>{parameter.name}</label>
<input className='argo-field' defaultValue={parameter.value || parameter.default} onChange={event => setParameter(parameter.name, event.target.value)} />
</React.Fragment>
);
};

const renderFields = (parameter: Parameter) => {
if (parameter.enum) {
return renderSelectField(parameter);
}
return renderInputField(parameter);
};

const renderInputContentIfApplicable = () => {
if (parameters.length === 0) {
return <React.Fragment />;
}
return (
<React.Fragment>
<h2>Modify parameters</h2>
{parameters.map(renderFields)}
<br />
</React.Fragment>
);
};

return (
<div>
{renderInputContentIfApplicable()}
<br />
Are you sure you want to resume node {props.nodeId} ?
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as classNames from 'classnames';
import * as React from 'react';
import {useContext, useEffect, useState} from 'react';
import {RouteComponentProps} from 'react-router';
import {execSpec, Link, NodeStatus, Workflow} from '../../../../models';
import {execSpec, Link, NodeStatus, Parameter, Workflow} from '../../../../models';
import {ANNOTATION_KEY_POD_NAME_VERSION} from '../../../shared/annotations';
import {uiUrl} from '../../../shared/base';
import {CostOptimisationNudge} from '../../../shared/components/cost-optimisation-nudge';
Expand All @@ -30,6 +30,7 @@ import {WorkflowParametersPanel} from '../workflow-parameters-panel';
import {WorkflowSummaryPanel} from '../workflow-summary-panel';
import {WorkflowTimeline} from '../workflow-timeline/workflow-timeline';
import {WorkflowYamlViewer} from '../workflow-yaml-viewer/workflow-yaml-viewer';
import {SuspendInputs} from './suspend-inputs';
import {WorkflowResourcePanel} from './workflow-resource-panel';

require('./workflow-details.scss');
Expand All @@ -50,6 +51,7 @@ export const WorkflowDetails = ({history, location, match}: RouteComponentProps<
const [nodeId, setNodeId] = useState(queryParams.get('nodeId'));
const [nodePanelView, setNodePanelView] = useState(queryParams.get('nodePanelView'));
const [sidePanel, setSidePanel] = useState(queryParams.get('sidePanel'));
const [parameters, setParameters] = useState<Parameter[]>([]);

useEffect(
useQueryParams(history, p => {
Expand All @@ -61,6 +63,19 @@ export const WorkflowDetails = ({history, location, match}: RouteComponentProps<
[history]
);

const getInputParametersForNode = (selectedWorkflowNodeId: string): Parameter[] => {
const selectedWorkflowNode = workflow && workflow.status && workflow.status.nodes && workflow.status.nodes[selectedWorkflowNodeId];
return (
selectedWorkflowNode?.inputs?.parameters?.map(param => {
const paramClone = {...param};
if (paramClone.enum) {
paramClone.value = paramClone.default;
}
return paramClone;
}) || []
);
};

useEffect(() => {
history.push(historyUrl('workflows/{namespace}/{name}', {namespace, name, tab, nodeId, nodePanelView, sidePanel}));
}, [namespace, name, tab, nodeId, nodePanelView, sidePanel]);
Expand All @@ -76,6 +91,10 @@ export const WorkflowDetails = ({history, location, match}: RouteComponentProps<
.catch(setError);
}, []);

useEffect(() => {
setParameters(getInputParametersForNode(nodeId));
}, [nodeId, workflow]);

const parsedSidePanel = parseSidePanelParam(sidePanel);

const getItems = () => {
Expand Down Expand Up @@ -226,10 +245,47 @@ export const WorkflowDetails = ({history, location, match}: RouteComponentProps<
}
};

const setParameter = (key: string, value: string) => {
setParameters(previous => {
return previous?.map(parameter => {
if (parameter.name === key) {
parameter.value = value;
}
return parameter;
});
});
};

const renderSuspendNodeOptions = () => {
return <SuspendInputs parameters={parameters} nodeId={nodeId} setParameter={setParameter} />;
};

const getParametersAsJsonString = () => {
const outputVariables: {[x: string]: string} = {};
parameters.forEach(param => {
outputVariables[param.name] = param.value;
});
return JSON.stringify(outputVariables);
};

const updateOutputParametersForNodeIfRequired = () => {
// No need to set outputs on node if there are no parameters
if (parameters.length > 0) {
return services.workflows.set(workflow.metadata.name, workflow.metadata.namespace, 'id=' + nodeId, getParametersAsJsonString());
}
return Promise.resolve(null);
};

const resumeNode = () => {
return services.workflows.resume(workflow.metadata.name, workflow.metadata.namespace, 'id=' + nodeId);
};

const renderResumePopup = () => {
return popup.confirm('Confirm', `Are you sure you want to resume node: ${nodeId}?`).then(yes => {
return popup.confirm('Confirm', renderSuspendNodeOptions).then(yes => {
if (yes) {
services.workflows.resume(workflow.metadata.name, workflow.metadata.namespace, 'id=' + nodeId).catch(setError);
updateOutputParametersForNodeIfRequired()
.then(resumeNode)
.catch(setError);
}
});
};
Expand Down
28 changes: 28 additions & 0 deletions workflow/controller/operator.go
Original file line number Diff line number Diff line change
Expand Up @@ -2949,6 +2949,7 @@ func (woc *wfOperationCtx) executeSuspend(nodeName string, templateScope string,
node := woc.wf.GetNodeByName(nodeName)
if node == nil {
node = woc.initializeExecutableNode(nodeName, wfv1.NodeTypeSuspend, templateScope, tmpl, orgTmpl, opts.boundaryID, wfv1.NodePending)
woc.resolveInputFieldsForSuspendNode(node)
}
woc.log.Infof("node %s suspended", nodeName)

Expand Down Expand Up @@ -2989,6 +2990,33 @@ func (woc *wfOperationCtx) executeSuspend(nodeName string, templateScope string,
return node, nil
}

func (woc *wfOperationCtx) resolveInputFieldsForSuspendNode(node *wfv1.NodeStatus) {
if node.Inputs == nil {
return
}
parameters := node.Inputs.Parameters
for i, parameter := range parameters {
if parameter.Value != nil {

value := parameter.Value.String()
tempParameter := wfv1.Parameter{}

if err := json.Unmarshal([]byte(value), &tempParameter); err != nil {
woc.log.Debugf("Unable to parse input string %s to Parameter %s, %v", value, parameter.Name, err)
continue
}

enum := tempParameter.Enum
if len(enum) > 0 {
parameters[i].Enum = enum
if parameters[i].Default == nil {
parameters[i].Default = wfv1.AnyStringPtr(enum[0])
}
}
}
}
}

func addRawOutputFields(node *wfv1.NodeStatus, tmpl *wfv1.Template) *wfv1.NodeStatus {
if tmpl.GetType() != wfv1.TemplateTypeSuspend || node.Type != wfv1.NodeTypeSuspend {
panic("addRawOutputFields should only be used for nodes and templates of type suspend")
Expand Down
Loading

0 comments on commit 02fb874

Please sign in to comment.