Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/configplan-approval.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added an `approval_state` filter and column to the Config Plan list view and a read-only `approval_state` field to the Config Plan REST API.
1 change: 1 addition & 0 deletions changes/configplan-approval.breaking
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Replaced Config Plan status-based approval with Nautobot's native Approval Workflow. New plans now trigger a `Pending` approval workflow on creation and the Deploy Config Plans job refuses to push configuration until that workflow is `Approved`.
1 change: 1 addition & 0 deletions changes/configplan-approval.documentation
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Updated the Config Plans user guide and the E3025 troubleshooting page to describe the new Approval Workflow integration, default approver/architect/operator groups, and admin override procedure.
1 change: 1 addition & 0 deletions changes/configplan-approval.removed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Removed the `default_deploy_status` plugin configuration key and the `Approved` and `Not Approved` Config Plan statuses; approval is now handled by Nautobot's Approval Workflow.
1 change: 0 additions & 1 deletion development/nautobot_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,6 @@
"lstrip_blocks": is_truthy(os.getenv("NAUTOBOT_JINJA_ENV_LSTRIP_BLOCKS", "false")),
},
# "get_custom_compliance": "my.custom_compliance.func",
# "default_deploy_status": "Not Approved",
#
#
# custom_dispatcher is not required for preferring a framework such as netmiko or napalm.
Expand Down
24 changes: 13 additions & 11 deletions docs/admin/troubleshooting/E3025.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
# E30XX Details
# E3025 Details

## Message emitted:
## Message emitted

`E30XX: Details coming soon`
`E3025: Cannot deploy configuration(s). One or more config plans have an approval workflow that is not in the` Approved `state.`

## Description:
## Description

Description that is coming soon.
The **Deploy Config Plans** job refuses to push configuration to devices when any plan in the batch has an associated Nautobot Approval Workflow that has not yet reached the `Approved` state (it is still `Pending`, was `Denied`, or was `Canceled`). Plans that have no associated workflow at all — i.e. an admin removed the `Config Plan Approval` workflow definition before the plan was generated — are allowed through.

## Troubleshooting:
## Troubleshooting

Troubleshooting that is coming soon.

## Recommendation:

Recommendation that is coming soon.
1. Identify the offending plan(s). The Config Plan list and detail views display an **Approval State** column / panel that surfaces each plan's current state and links to its workflow.
2. From the workflow detail page (or from the Approver Dashboard at `/extras/approver-dashboard/`), an approver in the `nautobot-default-configplan-approver` group can approve or deny the pending stage(s).
3. If the workflow was denied or canceled, generate a new Config Plan; the previous plan cannot be re-approved.

## Recommendation

- Approval is gated by the `Config Plan Approval` workflow seeded by the plugin. Admins can amend the approver group (or any other stage attribute) under **Extras → Approval Workflow Definitions**.
- To disable approvals entirely, delete the `Config Plan Approval` definition. New Config Plans generated afterwards will deploy without approval. Existing plans with pending workflows are still gated until those workflows reach `Approved`, are deleted, or the deploy is run with a different set of plans.
- The `Workflow Architect` persona (the `nautobot-default-configplan-architect` group) holds the permissions required to manage the workflow definition and its stages.
38 changes: 35 additions & 3 deletions docs/user/app_feature_config_plans.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ You can view a plan by navigating to **Golden Config -> Config Plans** and choos
- **Change Control ID** (Optional): A text field that be used for grouping and filtering plans.
- **Change Control URL** (Optional): A URL field that can be used to link to an external system tracking change controls.
- **Job Result**: The Job that generated the plan(s).
- **Status**: The status of the plan.
- **Status**: The deployment lifecycle status of the plan. Only deployment-related values (`In Progress`, `Completed`, `Failed`) are used; the field is empty until the deploy job runs. Approval is tracked separately, in the linked **Approval Workflow** (see [Approving Config Plans](#approving-config-plans)).
- **Approval State**: The current state of the plan's Approval Workflow (`Pending`, `Approved`, `Denied`, or `Canceled`).

![Config Plan View](../images/ss1_config_plan-view_light.png#only-light){ .on-glb }
![Config Plan View](../images/ss1_config_plan-view_dark.png#only-dark){ .on-glb }
Expand Down Expand Up @@ -60,17 +61,18 @@ After a Config Plan is generated you have the ability to edit (or bulk edit) the

- Change Control ID
- Change Control URL
- Status
- Notes
- Tags

The `Status` field is managed by the Deploy Config Plans job (it tracks the deployment lifecycle only — `In Progress`, `Completed`, `Failed`). Approval is no longer expressed through `Status`; see [Approving Config Plans](#approving-config-plans) below.

!!! note
You will not be able to modify the Config Set after generation. If it does not contain the desired commands, you will need to delete the plan and recreate it after ensuring the source of the generated commands has been updated.

![Config Plan Edit](../images/ss1_config_plan-edit_light.png#only-light){ .on-glb }
![Config Plan Edit](../images/ss1_config_plan-edit_dark.png#only-dark){ .on-glb }

If the Config Plan has post processing functions, you can render the post processed config to validate and approve a Config Plan.
If the Config Plan has post processing functions, you can render the post processed config before approving it.

![Config Plan Post Processing Button](../images/ss1_config_plan_pp_button_light.png#only-light){ .on-glb }
![Config Plan Post Processing Button](../images/ss1_config_plan_pp_button_dark.png#only-dark){ .on-glb }
Expand All @@ -79,3 +81,33 @@ Post Processing occurs in a modal popup, and allows a user to view the configura

![Intended Configuration Web UI](../images/ss1_config_plan_pp-rendered_light.png#only-light){ .on-glb }
![Intended Configuration Web UI](../images/ss1_config_plan_pp-rendered_dark.png#only-dark){ .on-glb }

## Approving Config Plans

Config Plan approval is implemented on top of Nautobot's native [Approval Workflow](https://docs.nautobot.com/projects/core/en/stable/user-guide/platform-functionality/approval-workflow/) subsystem. Every new Config Plan automatically gets a `Pending` Approval Workflow attached to it; the **Deploy Config Plans** job refuses to push configuration (error `E3025`) until that workflow is in the `Approved` state.

### Default Workflow & Groups

On first install (or upgrade from a release that still used status-based approval) the plugin seeds:

- A workflow definition named **`Config Plan Approval`** that targets `ConfigPlan` with no filter, so it matches every plan;
- A single approval stage requiring **1 approver** from the `nautobot-default-configplan-approver` group;
- Three groups modeling the standard Workflow personas:
- `nautobot-default-configplan-architect` — can create, change, and delete the workflow definition and its stages;
- `nautobot-default-configplan-approver` — can approve or deny pending stages;
- `nautobot-default-configplan-operator` — read-only view of all workflows.

Add the appropriate users to these groups after install. Approvers will then see pending Config Plan approvals on the standard Nautobot **Approver Dashboard** at `/extras/approver-dashboard/`.

### Customizing or Disabling Approval

Administrators can amend the seeded workflow under **Extras → Approval Workflow Definitions → Config Plan Approval**:

- Change `model_constraints` to limit approval to a subset of plans (for example, only plans where `plan_type="remediation"`).
- Edit the stage to require more approvers, change the approver group, or add additional stages.

To disable approval entirely, delete the `Config Plan Approval` workflow definition. New plans generated afterwards will have no associated workflow and the deploy job will allow them through. Existing plans whose workflow was already attached remain gated until that workflow reaches `Approved` or is otherwise resolved.

### Upgrade Note

On the upgrade migration that introduces this feature, every existing Config Plan that had not yet been deployed (status `Approved`, `Not Approved`, or empty) is given a fresh `Pending` approval workflow. The `Approved` and `Not Approved` Status records are then removed because they are no longer used. Plans already in `In Progress`, `Completed`, or `Failed` are left untouched.
1 change: 0 additions & 1 deletion nautobot_golden_config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ class GoldenConfig(NautobotAppConfig):
"enable_postprocessing": False,
"enable_plan": True,
"enable_deploy": True,
"default_deploy_status": "Not Approved",
"postprocessing_callables": [],
"postprocessing_subscribed": [],
"per_feature_bar_width": 0.3,
Expand Down
9 changes: 8 additions & 1 deletion nautobot_golden_config/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,19 @@ class Meta:
class ConfigPlanSerializer(NautobotModelSerializer, TaggedModelSerializerMixin):
"""Serializer for ConfigPlan object."""

approval_state = serializers.SerializerMethodField(read_only=True)

class Meta:
"""Set Meta Data for ConfigPlan, will serialize all fields."""

model = models.ConfigPlan
fields = "__all__"
read_only_fields = ["device", "plan_type", "feature", "config_set"]
read_only_fields = ["device", "plan_type", "feature", "config_set", "status"]

def get_approval_state(self, obj):
"""Return the current approval workflow state for this plan, or None if no workflow exists."""
workflow = obj.associated_approval_workflows.first()
return workflow.current_state if workflow else None


class GenerateIntendedConfigSerializer(serializers.Serializer): # pylint: disable=abstract-method
Expand Down
10 changes: 8 additions & 2 deletions nautobot_golden_config/details.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,12 @@ def hyperlinked_field_with_icon(url, title, icon_class="mdi mdi-text-box-check-o
"feature": [get_model_instances, helpers.placeholder],
},
),
ui.Panel(
label="Approval Workflow",
weight=150,
section=ui.SectionChoices.LEFT_HALF,
body_content_template_path="nautobot_golden_config/configplan_approval.html",
),
ui.ObjectFieldsPanel(
section=ui.SectionChoices.RIGHT_HALF,
label="Config Deployment Details",
Expand All @@ -214,12 +220,12 @@ def hyperlinked_field_with_icon(url, title, icon_class="mdi mdi-text-box-check-o
),
ui.Panel(
label="Postprocessed Config Set",
weight=100,
weight=200,
section=ui.SectionChoices.RIGHT_HALF,
body_content_template_path="nautobot_golden_config/configplan_postprocessing.html",
),
ui.ObjectTextPanel(
weight=200,
weight=300,
label="Config Set",
section=ui.SectionChoices.FULL_WIDTH,
object_field="config_set",
Expand Down
7 changes: 7 additions & 0 deletions nautobot_golden_config/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
TreeNodeMultipleChoiceFilter,
)
from nautobot.dcim.models import Device, DeviceType, Location, Manufacturer, Platform, Rack, RackGroup
from nautobot.extras.choices import ApprovalWorkflowStateChoices
from nautobot.extras.models import JobResult, Role, Status
from nautobot.tenancy.models import Tenant, TenantGroup

Expand Down Expand Up @@ -444,6 +445,12 @@ class ConfigPlanFilterSet(NautobotFilterSet):
to_field_name="name",
label="Status",
)
approval_state = django_filters.MultipleChoiceFilter(
field_name="associated_approval_workflows__current_state",
choices=ApprovalWorkflowStateChoices,
label="Approval State",
distinct=True,
)

class Meta:
"""Boilerplate filter Meta data for Config Plan."""
Expand Down
29 changes: 17 additions & 12 deletions nautobot_golden_config/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from nautobot.apps import forms
from nautobot.apps.forms import NautobotBulkEditForm, NautobotFilterForm, NautobotModelForm
from nautobot.dcim.models import Device, DeviceType, Location, Manufacturer, Platform, Rack, RackGroup
from nautobot.extras.choices import ApprovalWorkflowStateChoices
from nautobot.extras.models import DynamicGroup, GitRepository, GraphQLQuery, JobResult, Role, Status, Tag
from nautobot.tenancy.models import Tenant, TenantGroup
from packaging import version
Expand Down Expand Up @@ -568,12 +569,12 @@ class Meta:


class ConfigPlanUpdateForm(NautobotModelForm): # pylint: disable=nb-sub-class-name
"""Form for ConfigPlan instances."""
"""Form for ConfigPlan instances.

Status is owned by the deploy job (lifecycle tracking only) and approval is managed
via Nautobot's Approval Workflow, so neither is editable here.
"""

status = forms.DynamicModelChoiceField(
queryset=Status.objects.all(),
query_params={"content_types": models.ConfigPlan._meta.label_lower},
)
tags = forms.DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(), query_params={"content_types": "dcim.device"}, required=False
)
Expand All @@ -585,7 +586,6 @@ class Meta:
fields = ( # pylint: disable=nb-use-fields-all
"change_control_id",
"change_control_url",
"status",
"tags",
)

Expand Down Expand Up @@ -637,20 +637,25 @@ class ConfigPlanFilterForm(DeviceRelatedFilterForm):
label="Status",
to_field_name="name",
)
approval_state = django_forms.MultipleChoiceField(
choices=ApprovalWorkflowStateChoices,
required=False,
label="Approval State",
widget=forms.StaticSelect2Multiple(),
)
tags = forms.TagFilterField(model)


class ConfigPlanBulkEditForm(NautobotBulkEditForm):
"""BulkEdit form for ConfigPlan instances."""
"""BulkEdit form for ConfigPlan instances.

Status is owned by the deploy job and approval is managed via Nautobot's Approval
Workflow, so neither is editable here.
"""

pk = django_forms.ModelMultipleChoiceField(
queryset=models.ConfigPlan.objects.all(), widget=django_forms.MultipleHiddenInput
)
status = forms.DynamicModelChoiceField(
queryset=Status.objects.all(),
query_params={"content_types": models.ConfigPlan._meta.label_lower},
required=False,
)
change_control_id = django_forms.CharField(required=False, label="Change Control ID")
change_control_url = django_forms.URLField(required=False, label="Change Control URL", max_length=2048)

Expand Down
11 changes: 0 additions & 11 deletions nautobot_golden_config/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
from nautobot_golden_config.nornir_plays.config_intended import config_intended
from nautobot_golden_config.utilities import constant
from nautobot_golden_config.utilities.config_plan import (
config_plan_default_status,
generate_config_set_from_compliance_feature,
generate_config_set_from_manual,
)
Expand Down Expand Up @@ -421,14 +420,6 @@ def __init__(self, *args, **kwargs):
self._change_control_url = None
self._commands = None
self._device_qs = Device.objects.none()
self._plan_status = None

@property
def plan_status(self):
"""The default status for ConfigPlan."""
if self._plan_status is None:
self._plan_status = config_plan_default_status()
return self._plan_status

def _validate_inputs(self, data):
self._plan_type = data["plan_type"]
Expand Down Expand Up @@ -472,7 +463,6 @@ def _generate_config_plan_from_feature(self):
config_set=config_set,
change_control_id=self._change_control_id,
change_control_url=self._change_control_url,
status=self.plan_status,
plan_result=self.job_result,
)
config_plan.feature.set(features)
Expand Down Expand Up @@ -501,7 +491,6 @@ def _generate_config_plan_from_manual(self):
config_set=config_set,
change_control_id=self._change_control_id,
change_control_url=self._change_control_url,
status=self.plan_status,
plan_result=self.job_result,
)
self.logger.info(f"Config plan created for {device} with manual commands.", extra={"object": config_plan})
Expand Down
Loading
Loading