Skip to content

Commit 8fa7e24

Browse files
Automate Nautobot service account provisioning
When service account details are created in Vault (PasswordSafe), a Kubernetes secret is generated. Argo Events then triggers a Job that runs an Ansible playbook to ensure the user is created in Nautobot and a corresponding token is provisioned.
1 parent 4d2c5dc commit 8fa7e24

File tree

16 files changed

+460
-0
lines changed

16 files changed

+460
-0
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
- name: Nautobot service accounts for Undercloud services
3+
connection: local
4+
hosts: nautobot
5+
gather_facts: false
6+
7+
pre_tasks:
8+
- name: Load Nautobot credentials from environment
9+
ansible.builtin.set_fact:
10+
nautobot_data: "{{ lookup('env', 'EXTRA_VARS') | from_json }}"
11+
12+
- name: Decode Nautobot credentials
13+
ansible.builtin.set_fact:
14+
nautobot_hostname: "{{ nautobot_data.hostname | b64decode }}"
15+
nautobot_username: "{{ nautobot_data.username | b64decode }}"
16+
nautobot_password: "{{ nautobot_data.password | b64decode }}"
17+
nautobot_user_token: "{{ nautobot_data.token | b64decode }}"
18+
19+
- name: Ensure nautobot is up and responding
20+
ansible.builtin.uri:
21+
url: "https://{{ nautobot_hostname }}/health/"
22+
method: GET
23+
validate_certs: false
24+
register: nautobot_up_check
25+
until: nautobot_up_check.status == 200
26+
retries: 24 # Retries for 24 * 5 seconds = 120 seconds = 2 minutes
27+
delay: 5 # Every 5 seconds
28+
check_mode: false
29+
30+
roles:
31+
- role: users
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
---
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
from ansible.module_utils.basic import AnsibleModule
2+
import requests
3+
4+
5+
def check_existing_token(base_url, username, password, user_token):
6+
"""Check if a specific token exists for the user."""
7+
headers = {"Accept": "application/json"}
8+
tokens_url = f"{base_url}/api/users/tokens/"
9+
10+
try:
11+
response = requests.get(tokens_url, headers=headers, auth=(username, password))
12+
response.raise_for_status()
13+
except requests.exceptions.RequestException as e:
14+
return None, f"Failed to fetch tokens: {e}"
15+
16+
data = response.json()
17+
tokens = data.get("results", [])
18+
19+
if not tokens:
20+
return None, "No tokens found"
21+
22+
# Find the token matching user_token
23+
token = next((t for t in tokens if t.get("key") == user_token), None)
24+
if not token:
25+
return None, "Specified token not found for user"
26+
27+
return token, None
28+
29+
30+
def create_new_token(
31+
base_url, username, password, user_token, description="ansible-created-token"
32+
):
33+
"""Create a new Nautobot token using Basic Auth."""
34+
tokens_url = f"{base_url}/api/users/tokens/"
35+
headers = {"Content-Type": "application/json", "Accept": "application/json"}
36+
payload = {"key": user_token, "description": description, "write_enabled": True}
37+
38+
try:
39+
response = requests.post(
40+
tokens_url, headers=headers, json=payload, auth=(username, password)
41+
)
42+
response.raise_for_status()
43+
except requests.exceptions.RequestException as e:
44+
return None, f"Failed to create new token: {e}"
45+
46+
return response.json(), None
47+
48+
49+
def run_module():
50+
module_args = dict(
51+
base_url=dict(type="str", required=True),
52+
username=dict(type="str", required=True),
53+
password=dict(type="str", required=True, no_log=True),
54+
user_token=dict(type="str", required=True, no_log=True),
55+
create_if_notfound=dict(type="bool", default=True),
56+
token_description=dict(type="str", default="ansible-created-token"),
57+
)
58+
59+
module = AnsibleModule(argument_spec=module_args, supports_check_mode=True)
60+
result = dict(changed=False, token=None, message="")
61+
62+
base_url = module.params["base_url"].rstrip("/")
63+
username = module.params["username"]
64+
password = module.params["password"]
65+
user_token = module.params["user_token"]
66+
create_if_notfound = module.params["create_if_notfound"]
67+
token_description = module.params["token_description"]
68+
69+
if module.check_mode:
70+
module.exit_json(**result)
71+
72+
# Check existing token
73+
token, error = check_existing_token(base_url, username, password, user_token)
74+
75+
if token:
76+
result.update(
77+
changed=False,
78+
message=f"Found existing token for {username}",
79+
token=dict(
80+
id=str(token.get("id")),
81+
display=str(token.get("display")),
82+
created=str(token.get("created")),
83+
expires=str(token.get("expires")),
84+
write_enabled=bool(token.get("write_enabled")),
85+
description=str(token.get("description", "No description")),
86+
),
87+
)
88+
module.exit_json(**result)
89+
90+
# No token found → create new if allowed
91+
if create_if_notfound:
92+
new_token, err = create_new_token(
93+
base_url, username, password, user_token, token_description
94+
)
95+
if err:
96+
module.fail_json(msg=err)
97+
result.update(
98+
changed=True,
99+
message=f"No token found, created new token for {username}",
100+
token=new_token,
101+
)
102+
module.exit_json(**result)
103+
104+
# No token and not allowed to create → fail
105+
module.fail_json(msg=f"No token found for {username} and creation disabled")
106+
107+
108+
def main():
109+
run_module()
110+
111+
112+
if __name__ == "__main__":
113+
main()
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
- name: Query user in Nautobot
3+
ansible.builtin.uri:
4+
url: "https://{{ nautobot_hostname }}/api/users/users/{{ nautobot_username }}"
5+
method: GET
6+
headers:
7+
Authorization: "Token {{ nautobot_token }}"
8+
return_content: true
9+
status_code: [200, 404]
10+
register: user_query_result
11+
12+
- name: Create user in Nautobot if missing
13+
ansible.builtin.uri:
14+
url: "https://{{ nautobot_hostname }}/api/users/users/"
15+
method: POST
16+
headers:
17+
Authorization: "Token {{ nautobot_token }}"
18+
Content-Type: "application/json"
19+
body_format: json
20+
body:
21+
username: "{{ nautobot_username }}"
22+
password: "{{ nautobot_password }}"
23+
is_staff: true
24+
is_superuser: true
25+
status_code: [201]
26+
when: user_query_result.status == 404
27+
28+
- name: Ensure Nautobot token exists for user
29+
nautobot_token:
30+
base_url: "https://{{ nautobot_hostname }}"
31+
username: "{{ nautobot_username }}"
32+
password: "{{ nautobot_password }}"
33+
user_token: "{{ nautobot_user_token }}"
34+
register: nautobot_token_result
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
component: nautobot-secrets
3+
componentNamespace: nautobot
4+
sources:
5+
- ref: understack
6+
path: 'components/secretstore-gen-secrets'
7+
helm:
8+
releaseName: nautobot-secrets
9+
valueFiles:
10+
- $deploy/{{.name}}/helm-configs/secretstore-nautobot-secrets.yaml
11+
ignoreMissingValueFiles: true
12+
- ref: deploy
13+
path: '{{.name}}/manifests/secret-store'
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Patterns to ignore when building packages.
2+
# This supports shell glob matching, relative path matching, and
3+
# negation (prefixed with !). Only one pattern per line.
4+
.DS_Store
5+
# Common VCS dirs
6+
.git/
7+
.gitignore
8+
.bzr/
9+
.bzrignore
10+
.hg/
11+
.hgignore
12+
.svn/
13+
# Common backup files
14+
*.swp
15+
*.bak
16+
*.tmp
17+
*.orig
18+
*~
19+
# Various IDEs
20+
.project
21+
.idea/
22+
*.tmproj
23+
.vscode/
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
apiVersion: v2
2+
name: site-secrets
3+
description: Orchestrating secrets across kubernetes clusters (global-site) using External SecretStore
4+
type: application
5+
version: 0.1.0
6+
appVersion: "1.0"
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{{- $site := .Values.site }}
2+
{{- $secretStore := $site.secretStore }}
3+
{{- range $site.secrets }}
4+
---
5+
apiVersion: external-secrets.io/v1
6+
kind: ExternalSecret
7+
metadata:
8+
name: {{ .name }}
9+
{{- if .externalLinkAnnotationTemplate }}
10+
annotations:
11+
link.argocd.argoproj.io/external-link: {{ tpl .externalLinkAnnotationTemplate . }}
12+
{{- end }}
13+
{{- with .labels }}
14+
labels:
15+
{{ toYaml . | indent 4 }}
16+
{{- end }}
17+
spec:
18+
refreshInterval: {{ .refreshInterval | default "1h" }}
19+
secretStoreRef:
20+
kind: {{ $secretStore.kind }}
21+
name: {{ $secretStore.name }}
22+
target:
23+
name: {{ .name }}
24+
creationPolicy: Owner
25+
template:
26+
engineVersion: v2
27+
type: {{ .templateType | default "Opaque" }}
28+
{{- with .labels }}
29+
metadata:
30+
labels:
31+
{{ toYaml . | indent 10 }}
32+
{{- end }}
33+
{{- if .templateData }}
34+
data:
35+
{{- range $k, $v := .templateData }}
36+
{{ $k }}: {{ $v | quote }}
37+
{{- end }}
38+
{{- end }}
39+
{{- if .data }}
40+
data:
41+
{{- range .data }}
42+
- secretKey: {{ .secretKey }}
43+
remoteRef:
44+
key: {{ .remoteRef.key }}
45+
property: {{ .remoteRef.property }}
46+
conversionStrategy: {{ .remoteRef.conversionStrategy | default "Default" }}
47+
decodingStrategy: {{ .remoteRef.decodingStrategy | default "None" }}
48+
metadataPolicy: {{ .remoteRef.metadataPolicy | default "None" }}
49+
{{- end }}
50+
{{- end }}
51+
{{- if .dataFrom }}
52+
dataFrom:
53+
{{- toYaml .dataFrom | nindent 4 }}
54+
{{- end }}
55+
{{- end }}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
site:
2+
name: uc-iad3-dev
3+
partition: uc-dev
4+
env: dev
5+
role: aio
6+
secretStore:
7+
kind: SecretStore
8+
name: vault

workflows/kustomization.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ kind: Kustomization
44
resources:
55
- openstack
66
- argo-events
7+
- nautobot

0 commit comments

Comments
 (0)