Merge pull request #151 from ryanmerolle/dev

1.3.0
This commit is contained in:
Ryan Merolle 2023-06-23 15:02:53 -04:00 committed by GitHub
commit 175f8c369f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 624 additions and 268 deletions

View File

@ -1,8 +1,8 @@
ARG NETBOX_VARIANT=v3.4
ARG NETBOX_VARIANT=v3.5
FROM netboxcommunity/netbox:${NETBOX_VARIANT}
ARG NETBOX_INITIALIZERS_VARIANT=3.4.*
ARG NETBOX_INITIALIZERS_VARIANT=3.5.*
ARG DEBIAN_FRONTEND=noninteractive

View File

@ -7,7 +7,7 @@ from os.path import abspath, dirname, join
# Read secret from file
def _read_secret(secret_name, default=None):
try:
f = open("/run/secrets/" + secret_name, encoding="utf-8")
f = open(f"/run/secrets/{secret_name}", encoding="utf-8")
except OSError:
return default
else:
@ -74,8 +74,7 @@ REDIS = {
environ.get("REDIS_CACHE_PASSWORD", environ.get("REDIS_PASSWORD", "")),
),
"DATABASE": int(environ.get("REDIS_CACHE_DATABASE", 1)),
"SSL": environ.get("REDIS_CACHE_SSL", environ.get("REDIS_SSL", "False")).lower()
== "true",
"SSL": environ.get("REDIS_CACHE_SSL", environ.get("REDIS_SSL", "False")).lower() == "true",
"INSECURE_SKIP_TLS_VERIFY": environ.get(
"REDIS_CACHE_INSECURE_SKIP_TLS_VERIFY",
environ.get("REDIS_INSECURE_SKIP_TLS_VERIFY", "False"),
@ -252,9 +251,7 @@ REMOTE_AUTH_BACKEND = environ.get(
"netbox.authentication.RemoteUserBackend",
)
REMOTE_AUTH_HEADER = environ.get("REMOTE_AUTH_HEADER", "HTTP_REMOTE_USER")
REMOTE_AUTH_AUTO_CREATE_USER = (
environ.get("REMOTE_AUTH_AUTO_CREATE_USER", "True").lower() == "true"
)
REMOTE_AUTH_AUTO_CREATE_USER = environ.get("REMOTE_AUTH_AUTO_CREATE_USER", "True").lower() == "true"
REMOTE_AUTH_DEFAULT_GROUPS = list(
filter(None, environ.get("REMOTE_AUTH_DEFAULT_GROUPS", "").split(" ")),
)

View File

@ -78,20 +78,31 @@
"extensions": [
"DavidAnson.vscode-markdownlint",
"GitHub.codespaces",
"GitHub.copilot-labs",
"GitHub.vscode-pull-request-github",
"Gruntfuggly.todo-tree",
"Tyriar.sort-lines",
"aaron-bond.better-comments",
"batisteo.vscode-django",
"charliermarsh.ruff",
"codezombiech.gitignore",
"esbenp.prettier-vscode",
"exiasr.hadolint",
"formulahendry.auto-rename-tag",
"mintlify.document",
"ms-python.isort",
"ms-python.pylint",
"ms-python.python",
"ms-python.vscode-pylance",
"ms-vscode.makefile-tools",
"mutantdino.resourcemonitor",
"oderwat.indent-rainbow",
"paulomenezes.duplicated-code",
"redhat.vscode-yaml",
"searKing.preview-vscode",
"sourcery.sourcery"
"sourcery.sourcery",
"wholroyd.jinja",
"yzhang.markdown-all-in-one"
]
}
},

View File

@ -15,7 +15,7 @@ REDIS_DATABASE=0
REDIS_HOST=redis
REDIS_INSECURE_SKIP_TLS_VERIFY=false
REDIS_PASSWORD=H733Kdjndks81
SECRET_KEY=r8OwDznj!!dciP9ghmRfdu1Ysxm0AiPeDCQhKE+N_rClfWNj
SECRET_KEY=r8OwDznj!!dciP9ghmRfdu1Ysxm0AiPeDCQhKE+N_rClfWNjaa
SUPERUSER_API_TOKEN=0123456789abcdef0123456789abcdef01234567
SUPERUSER_EMAIL=admin@example.com
SUPERUSER_NAME=admin

View File

@ -10,5 +10,7 @@ pycodestyle
pydocstyle
pylint
pylint-django
ruff
sourcery-analytics
wily
yapf

View File

@ -1,3 +1,3 @@
[flake8]
max-line-length = 160
max-line-length = 140
extend-ignore = E203

View File

@ -23,14 +23,14 @@ body:
attributes:
label: NetBox access-list plugin version
description: What version of the NetBox access-list plugin are you currently running?
placeholder: v1.2.0
placeholder: v1.3.0
validations:
required: true
- type: input
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.4.3
placeholder: v3.5.4
validations:
required: true
- type: textarea

View File

@ -15,7 +15,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.4.3
placeholder: v3.5.4
validations:
required: true
- type: dropdown

View File

@ -1,3 +0,0 @@
[flake8]
max-line-length = 160
extend-ignore = E203

View File

@ -1,8 +0,0 @@
[settings]
profile = black
; vertical hanging indent mode also used in black configuration
multi_line_output = 3
; necessary because black expect the trailing comma
include_trailing_comma = true

View File

@ -1,3 +0,0 @@
{
"threshold": 10
}

47
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,47 @@
---
name: CI
on:
push:
pull_request:
# This ensures that previous jobs for the workflow are canceled when the ref is
# updated.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
run-lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
# Full git history is needed to get a proper list of changed files within `super-linter`
fetch-depth: 0
- name: Lint Code Base
uses: github/super-linter/slim@v4
env:
DEFAULT_BRANCH: dev
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SUPPRESS_POSSUM: true
LINTER_RULES_PATH: /
VALIDATE_ALL_CODEBASE: false
VALIDATE_DOCKERFILE: false
VALIDATE_JSCPD: true
FILTER_REGEX_EXCLUDE: (.*/)?(configuration/.*)
test:
runs-on: ubuntu-latest
name: Runs plugin tests
needs: run-lint
steps:
- id: git-checkout
name: Checkout
uses: actions/checkout@v3
- id: docker-test
name: Test the image
run: ./test.sh snapshot

View File

@ -1,30 +0,0 @@
---
# This workflow executes several linters on changed files based on languages used in your code base whenever
# you push a code or open a pull request.
#
# You can adjust the behavior by modifying this file.
# For more information, see:
# https://github.com/github/super-linter
name: Lint Code Base
on:
push:
branches: ["dev"]
pull_request:
branches: ["dev"]
jobs:
run-lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
# Full git history is needed to get a proper list of changed files within `super-linter`
fetch-depth: 0
- name: Lint Code Base
uses: github/super-linter/slim@v4
env:
VALIDATE_ALL_CODEBASE: false
DEFAULT_BRANCH: "dev"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,8 +0,0 @@
[settings]
profile = black
; vertical hanging indent mode also used in black configuration
multi_line_output = 3
; necessary because black expect the trailing comma
include_trailing_comma = true

4
.jscpd.json Normal file
View File

@ -0,0 +1,4 @@
{
"threshold": 10,
"ignore": ["**/migrations/**", "**/tests/**"]
}

View File

@ -9,21 +9,25 @@ repos:
- id: debug-statements
- id: end-of-file-fixer
- id: name-tests-test
args:
- "--django"
- id: requirements-txt-fixer
- id: trailing-whitespace
- repo: https://github.com/PyCQA/isort
rev: 5.11.4
rev: 5.12.0
hooks:
- id: isort
args:
- "--profile=black"
exclude: ^.devcontainer/
- repo: https://github.com/psf/black
rev: 22.12.0
rev: 23.3.0
hooks:
- id: black
language_version: python3
exclude: ^.devcontainer/
- repo: https://github.com/asottile/add-trailing-comma
rev: v2.4.0
rev: v2.5.1
hooks:
- id: add-trailing-comma
args:
@ -32,25 +36,36 @@ repos:
rev: 6.0.0
hooks:
- id: flake8
exclude: ^.devcontainer/
- repo: https://github.com/asottile/pyupgrade
rev: v3.3.1
rev: v3.7.0
hooks:
- id: pyupgrade
args:
- "--py39-plus"
- repo: https://github.com/adrienverge/yamllint
rev: v1.29.0
rev: v1.32.0
hooks:
- id: yamllint
- repo: https://github.com/econchick/interrogate
rev: 1.5.0
hooks:
- id: interrogate
args: [--fail-under=90, --verbose]
exclude: (^.devcontainer/|^netbox_acls/migrations/)
#- repo: https://github.com/Lucas-C/pre-commit-hooks-nodejs
# rev: v1.1.2
# hooks:
# - id: htmlhint
# args: [--config, .htmlhintrc]
- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.33.0
rev: v0.35.0
hooks:
- id: markdownlint
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.272
hooks:
- id: ruff
#- repo: local
# hooks:
# - id: wily
@ -59,3 +74,13 @@ repos:
# verbose: true
# language: python
# additional_dependencies: [wily]
#- repo: https://github.com/sourcery-ai/sourcery
# rev: v1.0.4b23
# hooks:
# - id: sourcery
# # The best way to use Sourcery in a pre-commit hook:
# # * review only changed lines:
# # * omit the summary
# args:
# - --diff=git diff HEAD
# - --no-summary

9
Dockerfile Normal file
View File

@ -0,0 +1,9 @@
ARG NETBOX_VARIANT=v3.5
FROM netboxcommunity/netbox:${NETBOX_VARIANT}
RUN mkdir -pv /plugins/netbox-acls
COPY . /plugins/netbox-acls
RUN /opt/netbox/venv/bin/python3 /plugins/netbox-acls/setup.py develop && \
cp -rf /plugins/netbox-acls/netbox_acls/ /opt/netbox/venv/lib/python3.10/site-packages/netbox_acls

View File

@ -1,4 +1,3 @@
include README.md
include LICENSE
recursive-include netbox_acls/templates *
recursive-include netbox_acls/static *

View File

@ -70,9 +70,13 @@ start:
.PHONY: all ## Run all PLUGIN DEV targets
all: setup makemigrations migrate collectstatic initializers start
#.PHONY: test
#test:
# ${VENV_PY_PATH} /opt/netbox/netbox/manage.py runserver test ${PLUGIN_NAME}
.PHONY: rebuild ## Run PLUGIN DEV targets to rebuild
rebuild: setup makemigrations migrate collectstatic start
.PHONY: test
test: setup
${VENV_PY_PATH} ${NETBOX_MANAGE_PATH}/manage.py makemigrations ${PLUGIN_NAME} --check
${VENV_PY_PATH} ${NETBOX_MANAGE_PATH}/manage.py test ${PLUGIN_NAME}
#relpatch:
# $(eval GSTATUS := $(shell git status --porcelain))

View File

@ -41,6 +41,7 @@ Each Plugin Version listed below has been tested with its corresponding NetBox V
| 3.2 | 1.0.1 |
| 3.3 | 1.1.0 |
| 3.4 | 1.2.2 |
| 3.5 | 1.3.0 |
## Installing

7
TODO Normal file
View File

@ -0,0 +1,7 @@
- TODO: ACL Form Bubble/ICON Extended/Standard
- TODO: Add an Access List to an Interface Custom Fields after comments - DONE
- TODO: ACL rules, look at last number and increment to next 10
- TODO: Clone for ACL Interface should include device
- TODO: Inconsistent errors for add/edit (where model is using a generic page)
- TODO: Check Constants across codebase for consistency.
- TODO: Test API, Forms, & Models - https://github.com/k01ek/netbox-bgp/tree/main/netbox_bgp/tests , https://github.com/DanSheps/netbox-secretstore/tree/develop/netbox_secretstore/tests & https://github.com/FlxPeters/netbox-plugin-prometheus-sd/tree/main/netbox_prometheus_sd/tests

View File

@ -0,0 +1,99 @@
####
## We recommend to not edit this file.
## Create separate files to overwrite the settings.
## See `extra.py` as an example.
####
from os import environ
from os.path import abspath, dirname
# For reference see https://netbox.readthedocs.io/en/stable/configuration/
# Based on https://github.com/netbox-community/netbox/blob/master/netbox/netbox/configuration.example.py
# Read secret from file
def _read_secret(secret_name, default=None):
try:
f = open(f"/run/secrets/{secret_name}", encoding="utf-8")
except OSError:
return default
else:
with f:
return f.readline().strip()
_BASE_DIR = dirname(dirname(abspath(__file__)))
#########################
# #
# Required settings #
# #
#########################
# This is a list of valid fully-qualified domain names (FQDNs) for the NetBox server. NetBox will not permit write
# access to the server via any other hostnames. The first FQDN in the list will be treated as the preferred name.
#
# Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local']
ALLOWED_HOSTS = environ.get("ALLOWED_HOSTS", "*").split(" ")
# PostgreSQL database configuration. See the Django documentation for a complete list of available parameters:
# https://docs.djangoproject.com/en/stable/ref/settings/#databases
DATABASE = {
"NAME": environ.get("DB_NAME", "netbox"), # Database name
"USER": environ.get("DB_USER", ""), # PostgreSQL username
"PASSWORD": _read_secret("db_password", environ.get("DB_PASSWORD", "")),
# PostgreSQL password
"HOST": environ.get("DB_HOST", "localhost"), # Database server
"PORT": environ.get("DB_PORT", ""), # Database port (leave blank for default)
"OPTIONS": {"sslmode": environ.get("DB_SSLMODE", "prefer")},
# Database connection SSLMODE
"CONN_MAX_AGE": int(environ.get("DB_CONN_MAX_AGE", "300")),
# Max database connection age
"DISABLE_SERVER_SIDE_CURSORS": environ.get(
"DB_DISABLE_SERVER_SIDE_CURSORS",
"False",
).lower()
== "true",
# Disable the use of server-side cursors transaction pooling
}
# Redis database settings. Redis is used for caching and for queuing background tasks such as webhook events. A separate
# configuration exists for each. Full connection details are required in both sections, and it is strongly recommended
# to use two separate database IDs.
REDIS = {
"tasks": {
"HOST": environ.get("REDIS_HOST", "localhost"),
"PORT": int(environ.get("REDIS_PORT", 6379)),
"PASSWORD": _read_secret("redis_password", environ.get("REDIS_PASSWORD", "")),
"DATABASE": int(environ.get("REDIS_DATABASE", 0)),
"SSL": environ.get("REDIS_SSL", "False").lower() == "true",
"INSECURE_SKIP_TLS_VERIFY": environ.get(
"REDIS_INSECURE_SKIP_TLS_VERIFY",
"False",
).lower()
== "true",
},
"caching": {
"HOST": environ.get("REDIS_CACHE_HOST", environ.get("REDIS_HOST", "localhost")),
"PORT": int(environ.get("REDIS_CACHE_PORT", environ.get("REDIS_PORT", 6379))),
"PASSWORD": _read_secret(
"redis_cache_password",
environ.get("REDIS_CACHE_PASSWORD", environ.get("REDIS_PASSWORD", "")),
),
"DATABASE": int(environ.get("REDIS_CACHE_DATABASE", 1)),
"SSL": environ.get("REDIS_CACHE_SSL", environ.get("REDIS_SSL", "False")).lower() == "true",
"INSECURE_SKIP_TLS_VERIFY": environ.get(
"REDIS_CACHE_INSECURE_SKIP_TLS_VERIFY",
environ.get("REDIS_INSECURE_SKIP_TLS_VERIFY", "False"),
).lower()
== "true",
},
}
# This key is used for secure generation of random numbers and strings. It must never be exposed outside of this file.
# For optimal security, SECRET_KEY should be at least 50 characters in length and contain a mix of letters, numbers, and
# symbols. NetBox will not run without this defined. For more information, see
# https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-SECRET_KEY
SECRET_KEY = _read_secret("secret_key", environ.get("SECRET_KEY", ""))
DEVELOPER = True

11
configuration/logging.py Normal file
View File

@ -0,0 +1,11 @@
# Remove first comment(#) on each line to implement this working logging example.
# Add LOGLEVEL environment variable to netbox if you use this example & want a different log level.
from os import environ
# Set LOGLEVEL in netbox.env or docker-compose.overide.yml to override a logging level of INFO.
LOGLEVEL = environ.get("LOGLEVEL", "INFO")
LOGGING = {
"version": 1,
"disable_existing_loggers": True,
}

13
configuration/plugins.py Normal file
View File

@ -0,0 +1,13 @@
# Add your plugins and plugin settings here.
# Of course uncomment this file out.
# To learn how to build images with your required plugins
# See https://github.com/netbox-community/netbox-docker/wiki/Using-Netbox-Plugins
PLUGINS = [
"netbox_acls",
]
PLUGINS_CONFIG = { # type: ignore
"netbox_acls": {},
}

30
docker-compose.yml Normal file
View File

@ -0,0 +1,30 @@
---
version: '3.4'
services:
netbox:
build:
dockerfile: Dockerfile
context: .
args:
NETBOX_VARIANT: ${NETBOX_VARIANT}
depends_on:
- postgres
- redis
env_file: env/netbox.env
volumes:
- ./configuration:/etc/netbox/config:z,ro
# postgres
postgres:
image: postgres:14-alpine
env_file: env/postgres.env
# redis
redis:
image: redis:6-alpine
command:
- sh
- -c # this is to evaluate the $REDIS_PASSWORD from the env
- redis-server --appendonly yes --requirepass $$REDIS_PASSWORD ## $$ because of docker-compose
env_file: env/redis.env

23
env/netbox.env vendored Normal file
View File

@ -0,0 +1,23 @@
ALLOWED_HOSTS=*
CORS_ORIGIN_ALLOW_ALL=true
DB_HOST=postgres
DB_NAME=netbox
DB_PASSWORD=J5brHrAXFLQSif0K
DB_USER=netbox
DEBUG=true
ENFORCE_GLOBAL_UNIQUE=true
LOGIN_REQUIRED=false
GRAPHQL_ENABLED=true
MAX_PAGE_SIZE=1000
MEDIA_ROOT=/opt/netbox/netbox/media
REDIS_DATABASE=0
REDIS_HOST=redis
REDIS_INSECURE_SKIP_TLS_VERIFY=false
REDIS_PASSWORD=H733Kdjndks81
SECRET_KEY=r8OwDznj!!dciP9ghmRfdu1Ysxm0AiPeDCQhKE+N_rClfWNjaa
SUPERUSER_API_TOKEN=0123456789abcdef0123456789abcdef01234567
SUPERUSER_EMAIL=admin@example.com
SUPERUSER_NAME=admin
SUPERUSER_PASSWORD=admin
STARTUP_SCRIPTS=false
WEBHOOKS_ENABLED=true

3
env/postgres.env vendored Normal file
View File

@ -0,0 +1,3 @@
POSTGRES_DB=netbox
POSTGRES_PASSWORD=J5brHrAXFLQSif0K
POSTGRES_USER=netbox

1
env/redis.env vendored Normal file
View File

@ -0,0 +1 @@
REDIS_PASSWORD=H733Kdjndks81

View File

@ -17,8 +17,8 @@ class NetBoxACLsConfig(PluginConfig):
version = __version__
description = "Manage simple ACLs in NetBox"
base_url = "access-lists"
min_version = "3.4.0"
max_version = "3.4.99"
min_version = "3.5.0"
max_version = "3.5.99"
config = NetBoxACLsConfig

View File

@ -4,8 +4,7 @@ while Django itself handles the database abstraction.
"""
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from drf_yasg.utils import swagger_serializer_method
from drf_spectacular.utils import extend_schema_field
from ipam.api.serializers import NestedPrefixSerializer
from netbox.api.fields import ContentTypeField
from netbox.api.serializers import NetBoxModelSerializer
@ -32,13 +31,9 @@ __all__ = [
# Sets a standard error message for ACL rules with an action of remark, but no remark set.
error_message_no_remark = "Action is set to remark, you MUST add a remark."
# Sets a standard error message for ACL rules with an action of remark, but no source_prefix is set.
error_message_action_remark_source_prefix_set = (
"Action is set to remark, Source Prefix CANNOT be set."
)
error_message_action_remark_source_prefix_set = "Action is set to remark, Source Prefix CANNOT be set."
# Sets a standard error message for ACL rules with an action not set to remark, but no remark is set.
error_message_remark_without_action_remark = (
"CANNOT set remark unless action is set to remark."
)
error_message_remark_without_action_remark = "CANNOT set remark unless action is set to remark."
# Sets a standard error message for ACL rules no associated to an ACL of the same type.
error_message_acl_type = "Provided parent Access List is not of right type."
@ -81,7 +76,7 @@ class AccessListSerializer(NetBoxModelSerializer):
"rule_count",
)
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@extend_schema_field(serializers.DictField())
def get_assigned_object(self, obj):
serializer = get_serializer_for_model(
obj.assigned_object,
@ -98,27 +93,8 @@ class AccessListSerializer(NetBoxModelSerializer):
"""
error_message = {}
# Check that the GFK object is valid.
if "assigned_object_type" in data and "assigned_object_id" in data:
# TODO: This can removed after https://github.com/netbox-community/netbox/issues/10221 is fixed.
try:
assigned_object = data[ # noqa: F841
"assigned_object_type"
].get_object_for_this_type(
id=data["assigned_object_id"],
)
except ObjectDoesNotExist:
# Sets a standard error message for invalid GFK
error_message_invalid_gfk = f"Invalid assigned_object {data['assigned_object_type']} ID {data['assigned_object_id']}"
error_message["assigned_object_type"] = [error_message_invalid_gfk]
error_message["assigned_object_id"] = [error_message_invalid_gfk]
# Check if Access List has no existing rules before change the Access List's type.
if (
self.instance
and self.instance.type != data.get("type")
and self.instance.rule_count > 0
):
if self.instance and self.instance.type != data.get("type") and self.instance.rule_count > 0:
error_message["type"] = [
"This ACL has ACL rules associated, CANNOT change ACL type.",
]
@ -164,7 +140,7 @@ class ACLInterfaceAssignmentSerializer(NetBoxModelSerializer):
"last_updated",
)
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@extend_schema_field(serializers.DictField())
def get_assigned_object(self, obj):
serializer = get_serializer_for_model(
obj.assigned_object,
@ -182,40 +158,15 @@ class ACLInterfaceAssignmentSerializer(NetBoxModelSerializer):
error_message = {}
acl_host = data["access_list"].assigned_object
# Check that the GFK object is valid.
if "assigned_object_type" in data and "assigned_object_id" in data:
# TODO: This can removed after https://github.com/netbox-community/netbox/issues/10221 is fixed.
try:
assigned_object = data[ # noqa: F841
"assigned_object_type"
].get_object_for_this_type(
id=data["assigned_object_id"],
)
except ObjectDoesNotExist:
# Sets a standard error message for invalid GFK
error_message_invalid_gfk = f"Invalid assigned_object {data['assigned_object_type']} ID {data['assigned_object_id']}"
error_message["assigned_object_type"] = [error_message_invalid_gfk]
error_message["assigned_object_id"] = [error_message_invalid_gfk]
if data["assigned_object_type"].model == "interface":
interface_host = (
data["assigned_object_type"]
.get_object_for_this_type(id=data["assigned_object_id"])
.device
)
interface_host = data["assigned_object_type"].get_object_for_this_type(id=data["assigned_object_id"]).device
elif data["assigned_object_type"].model == "vminterface":
interface_host = (
data["assigned_object_type"]
.get_object_for_this_type(id=data["assigned_object_id"])
.virtual_machine
)
interface_host = data["assigned_object_type"].get_object_for_this_type(id=data["assigned_object_id"]).virtual_machine
else:
interface_host = None
# Check that the associated interface's parent host has the selected ACL defined.
if acl_host != interface_host:
error_acl_not_assigned_to_host = (
"Access List not present on the selected interface's host."
)
error_acl_not_assigned_to_host = "Access List not present on the selected interface's host."
error_message["access_list"] = [error_acl_not_assigned_to_host]
error_message["assigned_object_id"] = [error_acl_not_assigned_to_host]

View File

@ -10,6 +10,5 @@ ACL_HOST_ASSIGNMENT_MODELS = Q(
)
ACL_INTERFACE_ASSIGNMENT_MODELS = Q(
Q(app_label="dcim", model="interface")
| Q(app_label="virtualization", model="vminterface"),
Q(app_label="dcim", model="interface") | Q(app_label="virtualization", model="vminterface"),
)

View File

@ -7,11 +7,11 @@ Draft for a possible BulkEditForm, but may not be worth wile.
# from django.core.exceptions import ValidationError
# from django.utils.safestring import mark_safe
# from netbox.forms import NetBoxModelBulkEditForm
# from utilities.forms import (
# from utilities.forms.utils import add_blank_choice
# from utilities.forms.fields import (
# ChoiceField,
# DynamicModelChoiceField,
# StaticSelect,
# add_blank_choice,
# )
# from virtualization.models import VirtualMachine

View File

@ -6,13 +6,13 @@ from dcim.models import Device, Interface, Region, Site, SiteGroup, VirtualChass
from django import forms
from ipam.models import Prefix
from netbox.forms import NetBoxModelFilterSetForm
from utilities.forms import (
from utilities.forms.fields import (
ChoiceField,
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
TagFilterField,
add_blank_choice,
)
from utilities.forms.utils import add_blank_choice
from virtualization.models import VirtualMachine, VMInterface
from ..choices import (

View File

@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe
from ipam.models import Prefix
from netbox.forms import NetBoxModelForm
from utilities.forms import CommentField, DynamicModelChoiceField
from utilities.forms.fields import CommentField, DynamicModelChoiceField
from virtualization.models import (
Cluster,
ClusterGroup,
@ -39,20 +39,14 @@ help_text_acl_rule_logic = mark_safe(
# Sets a standard help_text value to be used by the various classes for acl action
help_text_acl_action = "Action the rule will take (remark, deny, or allow)."
# Sets a standard help_text value to be used by the various classes for acl index
help_text_acl_rule_index = (
"Determines the order of the rule in the ACL processing. AKA Sequence Number."
)
help_text_acl_rule_index = "Determines the order of the rule in the ACL processing. AKA Sequence Number."
# Sets a standard error message for ACL rules with an action of remark, but no remark set.
error_message_no_remark = "Action is set to remark, you MUST add a remark."
# Sets a standard error message for ACL rules with an action of remark, but no source_prefix is set.
error_message_action_remark_source_prefix_set = (
"Action is set to remark, Source Prefix CANNOT be set."
)
error_message_action_remark_source_prefix_set = "Action is set to remark, Source Prefix CANNOT be set."
# Sets a standard error message for ACL rules with an action not set to remark, but no remark is set.
error_message_remark_without_action_remark = (
"CANNOT set remark unless action is set to remark."
)
error_message_remark_without_action_remark = "CANNOT set remark unless action is set to remark."
class AccessListForm(NetBoxModelForm):
@ -65,19 +59,20 @@ class AccessListForm(NetBoxModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
initial_params={
"sites": "$site",
},
)
site_group = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label="Site Group",
initial_params={"sites": "$site"},
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
"region_id": "$region",
"group_id": "$site_group",
},
query_params={"region_id": "$region", "group_id": "$site_group"},
)
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
@ -101,32 +96,24 @@ class AccessListForm(NetBoxModelForm):
queryset=ClusterType.objects.all(),
required=False,
)
cluster_group = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
query_params={
"type_id": "$cluster_type",
},
query_params={"type_id": "$cluster_type"},
)
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
required=False,
query_params={
"type_id": "$cluster_type",
"group_id": "$cluster_group",
},
query_params={"type_id": "$cluster_type", "group_id": "$cluster_group"},
)
virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
label="Virtual Machine",
query_params={
"cluster_id": "$cluster",
"cluster_type_id": "$cluster_type",
"cluster_group_id": "$cluster_group",
"cluster_id": "$cluster",
},
)
@ -156,19 +143,32 @@ class AccessListForm(NetBoxModelForm):
}
def __init__(self, *args, **kwargs):
# Initialize helper selectors
instance = kwargs.get("instance")
initial = kwargs.get("initial", {}).copy()
if instance:
if type(instance.assigned_object) is Device:
if isinstance(instance.assigned_object, Device):
initial["device"] = instance.assigned_object
elif type(instance.assigned_object) is VirtualChassis:
initial["virtual_chassis"] = instance.assigned_object
elif type(instance.assigned_object) is VirtualMachine:
initial["virtual_machine"] = instance.assigned_object
kwargs["initial"] = initial
if instance.assigned_object.site:
initial["site"] = instance.assigned_object.site
if instance.assigned_object.site.group:
initial["site_group"] = instance.assigned_object.site.group
if instance.assigned_object.site.region:
initial["region"] = instance.assigned_object.site.region
elif isinstance(instance.assigned_object, VirtualMachine):
initial["virtual_machine"] = instance.assigned_object
if instance.assigned_object.cluster:
initial["cluster"] = instance.assigned_object.cluster
if instance.assigned_object.cluster.group:
initial["cluster_group"] = instance.assigned_object.cluster.group
if instance.assigned_object.cluster.type:
initial["cluster_type"] = instance.assigned_object.cluster.type
elif isinstance(instance.assigned_object, VirtualChassis):
initial["virtual_chassis"] = instance.assigned_object
kwargs["initial"] = initial
super().__init__(*args, **kwargs)
def clean(self):
@ -190,13 +190,9 @@ class AccessListForm(NetBoxModelForm):
virtual_machine = cleaned_data.get("virtual_machine")
# Check if more than one host type selected.
if (
(device and virtual_chassis)
or (device and virtual_machine)
or (virtual_chassis and virtual_machine)
):
if (device and virtual_chassis) or (device and virtual_machine) or (virtual_chassis and virtual_machine):
raise forms.ValidationError(
"Access Lists must be assigned to one host (either a device, virtual chassis or virtual machine) at a time.",
"Access Lists must be assigned to one host at a time. Either a device, virtual chassis or virtual machine."
)
# Check if no hosts selected.
if not device and not virtual_chassis and not virtual_machine:
@ -221,24 +217,16 @@ class AccessListForm(NetBoxModelForm):
).exists()
# Check if duplicate entry.
if (
"name" in self.changed_data or host_type in self.changed_data
) and existing_acls:
error_same_acl_name = (
"An ACL with this name is already associated to this host."
)
if ("name" in self.changed_data or host_type in self.changed_data) and existing_acls:
error_same_acl_name = "An ACL with this name is already associated to this host."
error_message |= {
host_type: [error_same_acl_name],
"name": [error_same_acl_name],
}
if self.instance.pk:
# Check if Access List has no existing rules before change the Access List's type.
if (
acl_type == ACLTypeChoices.TYPE_EXTENDED
and self.instance.aclstandardrules.exists()
) or (
acl_type == ACLTypeChoices.TYPE_STANDARD
and self.instance.aclextendedrules.exists()
if self.instance.pk and (
(acl_type == ACLTypeChoices.TYPE_EXTENDED and self.instance.aclstandardrules.exists())
or (acl_type == ACLTypeChoices.TYPE_STANDARD and self.instance.aclextendedrules.exists())
):
error_message["type"] = [
"This ACL has ACL rules associated, CANNOT change ACL type.",
@ -252,9 +240,7 @@ class AccessListForm(NetBoxModelForm):
def save(self, *args, **kwargs):
# Set assigned object
self.instance.assigned_object = (
self.cleaned_data.get("device")
or self.cleaned_data.get("virtual_chassis")
or self.cleaned_data.get("virtual_machine")
self.cleaned_data.get("device") or self.cleaned_data.get("virtual_chassis") or self.cleaned_data.get("virtual_machine")
)
return super().save(*args, **kwargs)
@ -315,7 +301,6 @@ class ACLInterfaceAssignmentForm(NetBoxModelForm):
comments = CommentField()
def __init__(self, *args, **kwargs):
# Initialize helper selectors
instance = kwargs.get("instance")
initial = kwargs.get("initial", {}).copy()
@ -367,15 +352,15 @@ class ACLInterfaceAssignmentForm(NetBoxModelForm):
# Check if both interface and vminterface are set.
if interface and vminterface:
error_too_many_interfaces = "Access Lists must be assigned to one type of interface at a time (VM interface or physical interface)"
error_too_many_interfaces = (
"Access Lists must be assigned to one type of interface at a time (VM interface or physical interface)"
)
error_message |= {
"interface": [error_too_many_interfaces],
"vminterface": [error_too_many_interfaces],
}
elif not (interface or vminterface):
error_no_interface = (
"An Access List assignment but specify an Interface or VM Interface."
)
error_no_interface = "An Access List assignment but specify an Interface or VM Interface."
error_message |= {
"interface": [error_no_interface],
"vminterface": [error_no_interface],
@ -401,9 +386,7 @@ class ACLInterfaceAssignmentForm(NetBoxModelForm):
# Check that an interface's parent device/virtual_machine is assigned to the Access List.
if access_list_host != host:
error_acl_not_assigned_to_host = (
"Access List not present on selected host."
)
error_acl_not_assigned_to_host = "Access List not present on selected host."
error_message |= {
"access_list": [error_acl_not_assigned_to_host],
assigned_object_type: [error_acl_not_assigned_to_host],
@ -428,9 +411,7 @@ class ACLInterfaceAssignmentForm(NetBoxModelForm):
assigned_object_type=assigned_object_type_id,
direction=direction,
).exists():
error_interface_already_assigned = (
"Interfaces can only have 1 Access List assigned in each direction."
)
error_interface_already_assigned = "Interfaces can only have 1 Access List assigned in each direction."
error_message |= {
"direction": [error_interface_already_assigned],
assigned_object_type: [error_interface_already_assigned],

View File

@ -0,0 +1,2 @@
from .schema import *
from .types import *

View File

@ -0,0 +1,22 @@
from graphene import ObjectType
from netbox.graphql.fields import ObjectField, ObjectListField
from .types import *
class Query(ObjectType):
"""
Defines the queries available to this plugin via the graphql api.
"""
access_list = ObjectField(AccessListType)
access_list_list = ObjectListField(AccessListType)
acl_extended_rule = ObjectField(ACLExtendedRuleType)
acl_extended_rule_list = ObjectListField(ACLExtendedRuleType)
acl_standard_rule = ObjectField(ACLStandardRuleType)
acl_standard_rule_list = ObjectListField(ACLStandardRuleType)
schema = Query

View File

@ -2,11 +2,9 @@
Define the object types and queries availble via the graphql api.
"""
from graphene import ObjectType
from netbox.graphql.fields import ObjectField, ObjectListField
from netbox.graphql.types import NetBoxObjectType
from . import filtersets, models
from .. import filtersets, models
__all__ = (
"AccessListType",
@ -15,10 +13,6 @@ __all__ = (
"ACLStandardRuleType",
)
#
# Object types
#
class AccessListType(NetBoxObjectType):
"""
@ -78,26 +72,3 @@ class ACLStandardRuleType(NetBoxObjectType):
model = models.ACLStandardRule
fields = "__all__"
filterset_class = filtersets.ACLStandardRuleFilterSet
#
# Queries
#
class Query(ObjectType):
"""
Defines the queries availible to this plugin via the graphql api.
"""
access_list = ObjectField(AccessListType)
access_list_list = ObjectListField(AccessListType)
acl_extended_rule = ObjectField(ACLExtendedRuleType)
acl_extended_rule_list = ObjectListField(ACLExtendedRuleType)
acl_standard_rule = ObjectField(ACLStandardRuleType)
acl_standard_rule_list = ObjectListField(ACLStandardRuleType)
schema = Query

View File

@ -282,7 +282,9 @@ class Migration(migrations.Migration):
},
),
# migrations.AddConstraint(
# model_name='accesslist',
# constraint=models.UniqueConstraint(fields=('assigned_object_type', 'assigned_object_id'), name='accesslist_assigned_object'),
# model_name="accesslist",
# constraint=models.UniqueConstraint(
# fields=("assigned_object_type", "assigned_object_id"), name="accesslist_assigned_object"
# ),
# ),
]

View File

@ -6,7 +6,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("netbox_acls", "0001_initial"),

View File

@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("netbox_acls", "0002_alter_accesslist_options_and_more"),
]
@ -15,28 +14,36 @@ class Migration(migrations.Migration):
model_name="accesslist",
name="custom_field_data",
field=models.JSONField(
blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder,
),
),
migrations.AlterField(
model_name="aclextendedrule",
name="custom_field_data",
field=models.JSONField(
blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder,
),
),
migrations.AlterField(
model_name="aclinterfaceassignment",
name="custom_field_data",
field=models.JSONField(
blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder,
),
),
migrations.AlterField(
model_name="aclstandardrule",
name="custom_field_data",
field=models.JSONField(
blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder,
),
),
]

View File

@ -20,6 +20,7 @@
<h5 class="card-header">Access List</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<caption>Access List</caption>
<tr>
<th scope="row">Type</th>
<td>{{ object.get_type_display }}</td>

View File

@ -7,6 +7,7 @@
<h5 class="card-header">ACL Extended Rule</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<caption>ACL Extended Rule</caption>
<tr>
<th scope="row">Access List</th>
<td>
@ -32,6 +33,7 @@
<h5 class="card-header">Details</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<caption>Details</caption>
<tr>
<th scope="row">Remark</th>
<td>{{ object.get_remark_display|placeholder }}</td>

View File

@ -8,6 +8,7 @@
<h5 class="card-header">ACL Interface Assignment</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<caption>Host</caption>
<tr>
<th scope="row">Host</th>
<td>

View File

@ -7,6 +7,7 @@
<h5 class="card-header">ACL Standard Rule</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<caption>ACL Standard Rule</caption>
<tr>
<th scope="row">Access List</th>
<td>
@ -32,6 +33,7 @@
<h5 class="card-header">Details</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<caption>Details</caption>
<tr>
<th scope="row">Remark</th>
<td>{{ object.get_remark_display|placeholder }}</td>

View File

View File

@ -0,0 +1,97 @@
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from rest_framework import status
from utilities.testing import APITestCase, APIViewTestCases
from netbox_acls.choices import *
from netbox_acls.models import *
class AppTest(APITestCase):
def test_root(self):
url = reverse("plugins-api:netbox_acls-api:api-root")
response = self.client.get(f"{url}?format=api", **self.header)
self.assertEqual(response.status_code, status.HTTP_200_OK)
class ACLTestCase(
APIViewTestCases.APIViewTestCase,
):
"""Test the AccessList Test"""
model = AccessList
view_namespace = "plugins-api:netbox_acls"
brief_fields = ["display", "id", "name", "url"]
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name="Site 1", slug="site-1")
manufacturer = Manufacturer.objects.create(
name="Manufacturer 1",
slug="manufacturer-1",
)
devicetype = DeviceType.objects.create(
manufacturer=manufacturer,
model="Device Type 1",
)
devicerole = DeviceRole.objects.create(
name="Device Role 1",
slug="device-role-1",
)
device = Device.objects.create(
name="Device 1",
site=site,
device_type=devicetype,
device_role=devicerole,
)
access_lists = (
AccessList(
name="testacl1",
assigned_object_type=ContentType.objects.get_for_model(Device),
assigned_object_id=device.id,
type=ACLTypeChoices.TYPE_STANDARD,
default_action=ACLActionChoices.ACTION_DENY,
),
AccessList(
name="testacl2",
assigned_object_type=ContentType.objects.get_for_model(Device),
assigned_object_id=device.id,
type=ACLTypeChoices.TYPE_STANDARD,
default_action=ACLActionChoices.ACTION_DENY,
),
AccessList(
name="testacl3",
assigned_object_type=ContentType.objects.get_for_model(Device),
assigned_object_id=device.id,
type=ACLTypeChoices.TYPE_STANDARD,
default_action=ACLActionChoices.ACTION_DENY,
),
)
AccessList.objects.bulk_create(access_lists)
cls.create_data = [
{
"name": "testacl4",
"assigned_object_type": "dcim.device",
"assigned_object_id": device.id,
"type": ACLTypeChoices.TYPE_STANDARD,
"default_action": ACLActionChoices.ACTION_DENY,
},
{
"name": "testacl5",
"assigned_object_type": "dcim.device",
"assigned_object_id": device.id,
"type": ACLTypeChoices.TYPE_EXTENDED,
"default_action": ACLActionChoices.ACTION_DENY,
},
{
"name": "testacl6",
"assigned_object_type": "dcim.device",
"assigned_object_id": device.id,
"type": ACLTypeChoices.TYPE_STANDARD,
"default_action": ACLActionChoices.ACTION_DENY,
},
]

View File

@ -5,7 +5,7 @@ Map Views to URLs.
from django.urls import include, path
from utilities.urls import get_model_urls
from . import models, views
from . import views
urlpatterns = (
# Access Lists
@ -47,7 +47,11 @@ urlpatterns = (
views.ACLInterfaceAssignmentEditView.as_view(),
name="aclinterfaceassignment_add",
),
# path('interface-assignments/edit/', views.ACLInterfaceAssignmentBulkEditView.as_view(), name='aclinterfaceassignment_bulk_edit'),
# path(
# "interface-assignments/edit/",
# views.ACLInterfaceAssignmentBulkEditView.as_view(),
# name="aclinterfaceassignment_bulk_edit"
# ),
path(
"interface-assignments/delete/",
views.ACLInterfaceAssignmentBulkDeleteView.as_view(),

View File

@ -1 +1 @@
__version__ = "1.2.2"
__version__ = "1.3.0"

View File

@ -50,7 +50,8 @@ class AccessListView(generic.ObjectView):
def get_extra_context(self, request, instance):
"""
Depending on the Access List type, the list view will return the required ACL Rule using the previous defined tables in tables.py.
Depending on the Access List type, the list view will return
the required ACL Rule using the previous defined tables in tables.py.
"""
if instance.type == choices.ACLTypeChoices.TYPE_EXTENDED:
@ -227,8 +228,7 @@ class ACLInterfaceAssignmentEditView(generic.ObjectEditView):
"""
return {
"access_list": request.GET.get("access_list")
or request.POST.get("access_list"),
"access_list": request.GET.get("access_list") or request.POST.get("access_list"),
"direction": request.GET.get("direction") or request.POST.get("direction"),
}
@ -360,8 +360,7 @@ class ACLStandardRuleEditView(generic.ObjectEditView):
"""
return {
"access_list": request.GET.get("access_list")
or request.POST.get("access_list"),
"access_list": request.GET.get("access_list") or request.POST.get("access_list"),
}
@ -443,8 +442,7 @@ class ACLExtendedRuleEditView(generic.ObjectEditView):
"""
return {
"access_list": request.GET.get("access_list")
or request.POST.get("access_list"),
"access_list": request.GET.get("access_list") or request.POST.get("access_list"),
}

23
pyproject.toml Normal file
View File

@ -0,0 +1,23 @@
[tool.black]
line-length = 140
[tool.isort]
profile = "black"
include_trailing_comma = true
multi_line_output = 3
[tool.pylint]
max-line-length = 140
[tool.pyright]
include = ["netbox_secrets"]
exclude = [
"**/node_modules",
"**/__pycache__",
]
reportMissingImports = true
reportMissingTypeStubs = false
[tool.ruff]
line-length = 140

View File

@ -1,20 +1,30 @@
"""
Configuration for setuptools.
"""
import codecs
import os.path
from setuptools import find_packages, setup
with open("README.md", encoding="utf-8") as fh:
long_description = fh.read()
script_dir = os.path.abspath(os.path.dirname(__file__))
with open(os.path.join(script_dir, "README.md"), encoding="utf-8") as fh:
long_description = fh.read().replace("(docs/img/", "(https://raw.githubusercontent.com/ryanmerolle/netbox-acls/release/docs/img/")
def read(rel_path):
here = os.path.abspath(os.path.dirname(__file__))
with codecs.open(os.path.join(here, rel_path), "r") as fp:
def read(relative_path):
"""
Read a file and return its contents.
"""
with codecs.open(os.path.join(script_dir, relative_path), "r") as fp:
return fp.read()
def get_version(rel_path):
for line in read(rel_path).splitlines():
def get_version(relative_path):
"""
Extract the version number from a file without importing it.
"""
for line in read(relative_path).splitlines():
if not line.startswith("__version__"):
raise RuntimeError("Unable to find version string.")
delim = '"' if '"' in line else "'"
@ -33,7 +43,19 @@ setup(
include_package_data=True,
zip_safe=False,
classifiers=[
"Framework :: Django",
"Development Status :: 5 - Production/Stable",
"Natural Language :: English",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Intended Audience :: System Administrators",
"Intended Audience :: Telecommunications Industry",
"Framework :: Django",
"Topic :: System :: Networking",
"Topic :: Internet",
],
)

38
test.sh Executable file
View File

@ -0,0 +1,38 @@
#!/bin/bash
# Runs the NetBox plugin unit tests
# Usage:
# ./test.sh latest
# ./test.sh v2.9.7
# ./test.sh develop-2.10
# exit when a command exits with an exit code != 0
set -e
# NETBOX_VARIANT is used by `Dockerfile` to determine the tag
NETBOX_VARIANT="${1-latest}"
# The docker compose command to use
doco="docker compose --file docker-compose.yml"
test_netbox_unit_tests() {
echo "⏱ Running NetBox Unit Tests"
$doco run --rm netbox python manage.py makemigrations netbox_acls --check
$doco run --rm netbox python manage.py test netbox_acls
}
test_cleanup() {
echo "💣 Cleaning Up"
$doco down -v
$doco rm -fsv
docker image rm docker.io/library/netbox-acls-netbox || echo ''
}
export NETBOX_VARIANT=${NETBOX_VARIANT}
echo "🐳🐳🐳 Start testing '${NETBOX_VARIANT}'"
# Make sure the cleanup script is executed
trap test_cleanup EXIT ERR
test_netbox_unit_tests
echo "🐳🐳🐳 Done testing '${NETBOX_VARIANT}'"