diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ce32f24 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.github +.travis.yml +build* +*.env diff --git a/Dockerfile b/Dockerfile index 02cdc08..7d657ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,11 @@ RUN apk add --no-cache \ postgresql-dev \ wget -RUN pip install gunicorn +RUN pip install \ +# gunicorn is used for launching netbox + gunicorn \ +# ruamel is used in startup_scripts + ruamel.yaml WORKDIR /opt @@ -31,11 +35,13 @@ RUN pip install -r requirements.txt COPY docker/configuration.docker.py /opt/netbox/netbox/netbox/configuration.py COPY docker/gunicorn_config.py /opt/netbox/ COPY docker/nginx.conf /etc/netbox-nginx/nginx.conf +COPY docker/docker-entrypoint.sh docker-entrypoint.sh +COPY startup_scripts/ /opt/netbox/startup_scripts/ +COPY initializers/ /opt/netbox/initializers/ WORKDIR /opt/netbox/netbox -COPY docker/docker-entrypoint.sh /docker-entrypoint.sh -ENTRYPOINT [ "/docker-entrypoint.sh" ] +ENTRYPOINT [ "/opt/netbox/docker-entrypoint.sh" ] VOLUME ["/etc/netbox-nginx/"] diff --git a/README.md b/README.md index 478dbe4..f3e4735 100644 --- a/README.md +++ b/README.md @@ -66,11 +66,11 @@ For example defining `ALLOWED_HOSTS=localhost ::1 127.0.0.1` would allows access [compose-env]: https://docs.docker.com/compose/environment-variables/ -### Custom Initialisation Code (e.g. Automatically Setting Up Custom Fields) +### Custom Initialization Code (e.g. Automatically Setting Up Custom Fields) -When using `docker-compose`, all the python scripts present in `docker/startup_scripts` will automatically be executed after the application boots in the context of `./manage.py`. +When using `docker-compose`, all the python scripts present in `/opt/netbox/startup_scripts` will automatically be executed after the application boots in the context of `./manage.py`. -That mechanism can be used for many things, and in particular to load Netbox custom fields: +That mechanism can be used for many things, e.g. to create Netbox custom fields: ```python # docker/startup_scripts/load_custom_fields.py @@ -94,12 +94,54 @@ if created: my_custom_field.obj_type.add(device_type) ``` +#### Initializers + +Initializers are built-in startup scripts for defining Netbox custom fields, groups and users. +All you need to do is to mount you own `initializers` folder ([see `docker-compose.yml`][netbox-docker-compose]). +Look at the [`initializers` folder][netbox-docker-initializers] to learn how the files must look like. + +Here's an example for defining a custom field: + +```yaml +# initializers/custom_fields.yml +text_field: + type: text + label: Custom Text + description: Enter text in a text field. + required: false + filterable: true + weight: 0 + on_objects: + - dcim.models.Device + - dcim.models.Rack + - ipam.models.IPAddress + - ipam.models.Prefix + - tenancy.models.Tenant + - virtualization.models.VirtualMachine +``` + +[netbox-docker-initializers]: https://github.com/ninech/netbox-docker/tree/master/initializers +[netbox-docker-compose]: https://github.com/ninech/netbox-docker/blob/master/docker-compose.yml + +#### Custom Docker Image + +You can also build your own Netbox Docker image containing your own startup scripts, custom fields, users and groups +like this: + +``` +ARG VERSION=latest +FROM ninech/netbox:$VERSION + +COPY startup_scripts/ /opt/netbox/startup_scripts/ +COPY initializers/ /opt/netbox/initializers/ +``` + ### Production The default settings are optimized for (local) development environments. You should therefore adjust the configuration for production setups, at least the following variables: -* `ALLOWED_HOSTS`: Add all URLs that lead to your netbox instance. +* `ALLOWED_HOSTS`: Add all URLs that lead to your Netbox instance. * `DB_*`: Use a persistent database. * `EMAIL_*`: Use your own mailserver. * `MAX_PAGE_SIZE`: Use the recommended default of 1000. diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 9177dd2..c0722d3 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -13,7 +13,7 @@ services: - ./manage.py - test postgres: - image: postgres:9.6-alpine + image: postgres:10.2-alpine env_file: postgres.env volumes: netbox-static-files: diff --git a/docker-compose.yml b/docker-compose.yml index 34e4572..0600b61 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,23 +10,24 @@ services: - postgres env_file: netbox.env volumes: - - ./docker/startup_scripts:/opt/netbox/netbox/startup_scripts + - ./startup_scripts:/opt/netbox/startup_scripts:ro + - ./initializers:/opt/netbox/initializers:ro - netbox-nginx-config:/etc/netbox-nginx/ - netbox-static-files:/opt/netbox/netbox/static - netbox-media-files:/opt/netbox/netbox/media - netbox-report-files:/opt/netbox/netbox/reports nginx: - image: nginx:1.11-alpine + image: nginx:1.13-alpine command: nginx -g 'daemon off;' -c /etc/netbox-nginx/nginx.conf depends_on: - netbox ports: - 8080 volumes: - - netbox-static-files:/opt/netbox/netbox/static - - netbox-nginx-config:/etc/netbox-nginx/ + - netbox-static-files:/opt/netbox/netbox/static:ro + - netbox-nginx-config:/etc/netbox-nginx/:ro postgres: - image: postgres:9.6-alpine + image: postgres:10.2-alpine env_file: postgres.env volumes: - netbox-postgres-data:/var/lib/postgresql/data diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index ff22cad..bcf418f 100755 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -39,7 +39,8 @@ if not User.objects.filter(username='${SUPERUSER_NAME}'): Token.objects.create(user=u, key='${SUPERUSER_API_TOKEN}') END -for script in $(ls startup_scripts/*.py 2> /dev/null); do +for script in /opt/netbox/startup_scripts/*.py; do + echo "⚙️ Executing '$script'" ./manage.py shell --plain < "${script}" done @@ -48,5 +49,6 @@ done echo "✅ Initialisation is done." -# launch whatever is passed by docker via RUN +# launch whatever is passed by docker +# (i.e. the RUN instruction in the Dockerfile) exec ${@} diff --git a/docker/startup_scripts/.gitkeep b/docker/startup_scripts/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/initializers/custom_fields.yml b/initializers/custom_fields.yml new file mode 100644 index 0000000..ccde9a0 --- /dev/null +++ b/initializers/custom_fields.yml @@ -0,0 +1,66 @@ +# text_field: +# type: text +# label: Custom Text +# description: Enter text in a text field. +# required: false +# filterable: true +# weight: 0 +# on_objects: +# - dcim.models.Device +# - dcim.models.Rack +# - ipam.models.IPAddress +# - ipam.models.Prefix +# - tenancy.models.Tenant +# - virtualization.models.VirtualMachine +# integer_field: +# type: integer +# label: Custom Number +# description: Enter numbers into an integer field. +# required: true +# filterable: true +# weight: 10 +# on_objects: +# - tenancy.models.Tenant +# selection_field: +# type: selection +# label: Choose between items +# required: false +# filterable: true +# weight: 30 +# on_objects: +# - dcim.models.Device +# choices: +# - value: First Item +# weight: 10 +# - value: Second Item +# weight: 20 +# - value: Third Item +# weight: 30 +# - value: Fifth Item +# weight: 50 +# - value: Fourth Item +# weight: 40 +# boolean_field: +# type: boolean +# label: Yes Or No? +# required: true +# filterable: true +# default: "false" # important: but "false" in quotes! +# weight: 90 +# on_objects: +# - dcim.models.Device +# url_field: +# type: url +# label: Hyperlink +# description: Link to something nice. +# required: true +# filterable: false +# on_objects: +# - tenancy.models.Tenant +# date_field: +# type: date +# label: Important Date +# required: false +# filterable: false +# on_objects: +# - dcim.models.Device diff --git a/initializers/groups.yml b/initializers/groups.yml new file mode 100644 index 0000000..1f4a5a7 --- /dev/null +++ b/initializers/groups.yml @@ -0,0 +1,9 @@ +# applications: +# users: +# - technical_user +# readers: +# users: +# - reader +# writers: +# users: +# - writer diff --git a/initializers/users.yml b/initializers/users.yml new file mode 100644 index 0000000..ed57fef --- /dev/null +++ b/initializers/users.yml @@ -0,0 +1,6 @@ +# technical_user: +# api_token: 0123456789technicaluser789abcdef01234567 # must be looooong! +# reader: +# password: reader +# writer: +# password: writer diff --git a/startup_scripts/00_users.py b/startup_scripts/00_users.py new file mode 100644 index 0000000..1db0ebe --- /dev/null +++ b/startup_scripts/00_users.py @@ -0,0 +1,20 @@ +from django.contrib.auth.models import Group, User +from users.models import Token + +from ruamel.yaml import YAML + +with open('/opt/netbox/initializers/users.yml', 'r') as stream: + yaml=YAML(typ='safe') + users = yaml.load(stream) + + if users is not None: + for username, user_details in users.items(): + if not User.objects.filter(username=username): + user = User.objects.create_user( + username = username, + password = user_details.get('password', 0) or User.objects.make_random_password) + + print("👤 Created user ",username) + + if user_details.get('api_token', 0): + Token.objects.create(user=user, key=user_details['api_token']) diff --git a/startup_scripts/10_groups.py b/startup_scripts/10_groups.py new file mode 100644 index 0000000..7932874 --- /dev/null +++ b/startup_scripts/10_groups.py @@ -0,0 +1,19 @@ +from django.contrib.auth.models import Group, User +from ruamel.yaml import YAML + +with open('/opt/netbox/initializers/groups.yml', 'r') as stream: + yaml=YAML(typ='safe') + groups = yaml.load(stream) + + if groups is not None: + for groupname, group_details in groups.items(): + group, created = Group.objects.get_or_create(name=groupname) + + if created: + print("👥 Created group", groupname) + + for username in group_details['users']: + user = User.objects.get(username=username) + + if user: + user.groups.add(group) diff --git a/startup_scripts/20_custom_fields.py b/startup_scripts/20_custom_fields.py new file mode 100644 index 0000000..5c40e37 --- /dev/null +++ b/startup_scripts/20_custom_fields.py @@ -0,0 +1,68 @@ +from extras.constants import CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_URL, CF_TYPE_SELECT +from extras.models import CustomField, CustomFieldChoice + +from ruamel.yaml import YAML + +text_to_fields = { + 'boolean': CF_TYPE_BOOLEAN, + 'date': CF_TYPE_DATE, + 'integer': CF_TYPE_INTEGER, + 'selection': CF_TYPE_SELECT, + 'text': CF_TYPE_TEXT, + 'url': CF_TYPE_URL, +} + +def get_class_for_class_path(class_path): + import importlib + from django.contrib.contenttypes.models import ContentType + + module_name, class_name = class_path.rsplit(".", 1) + module = importlib.import_module(module_name) + clazz = getattr(module, class_name) + return ContentType.objects.get_for_model(clazz) + +with open('/opt/netbox/initializers/custom_fields.yml', 'r') as stream: + yaml = YAML(typ='safe') + customfields = yaml.load(stream) + + if customfields is not None: + for cf_name, cf_details in customfields.items(): + custom_field, created = CustomField.objects.get_or_create(name = cf_name) + + if created: + if cf_details.get('default', 0): + custom_field.default = cf_details['default'] + + if cf_details.get('description', 0): + custom_field.description = cf_details['description'] + + if cf_details.get('filterable', 0): + custom_field.is_filterables = cf_details['filterable'] + + if cf_details.get('label', 0): + custom_field.label = cf_details['label'] + + for object_type in cf_details.get('on_objects', []): + custom_field.obj_type.add(get_class_for_class_path(object_type)) + + if cf_details.get('required', 0): + custom_field.required = cf_details['required'] + + if cf_details.get('type', 0): + custom_field.type = text_to_fields[cf_details['type']] + + if cf_details.get('weight', 0): + custom_field.weight = cf_details['weight'] + + custom_field.save() + + for choice_details in cf_details.get('choices', []): + choice = CustomFieldChoice.objects.create( + field=custom_field, + value=choice_details['value']) + + if choice_details.get('weight', 0): + choice.weight = choice_details['weight'] + choice.save() + + print("🔧 Created custom field", cf_name)