diff --git a/ansible/playbooks/nautobot-user-token.yaml b/ansible/playbooks/nautobot-user-token.yaml new file mode 100644 index 000000000..ec9edea9d --- /dev/null +++ b/ansible/playbooks/nautobot-user-token.yaml @@ -0,0 +1,31 @@ +--- +- name: Nautobot service accounts for Undercloud services + connection: local + hosts: nautobot + gather_facts: false + + pre_tasks: + - name: Load Nautobot credentials from environment + ansible.builtin.set_fact: + nautobot_data: "{{ lookup('env', 'EXTRA_VARS') | from_json }}" + + - name: Decode Nautobot credentials + ansible.builtin.set_fact: + nautobot_hostname: "{{ nautobot_data.hostname | b64decode }}" + nautobot_username: "{{ nautobot_data.username | b64decode }}" + nautobot_password: "{{ nautobot_data.password | b64decode }}" + nautobot_user_token: "{{ nautobot_data.token | b64decode }}" + + - name: Ensure nautobot is up and responding + ansible.builtin.uri: + url: "https://{{ nautobot_hostname }}/health/" + method: GET + validate_certs: false + register: nautobot_up_check + until: nautobot_up_check.status == 200 + retries: 24 # Retries for 24 * 5 seconds = 120 seconds = 2 minutes + delay: 5 # Every 5 seconds + check_mode: false + + roles: + - role: users diff --git a/ansible/roles/users/defaults/main.yaml b/ansible/roles/users/defaults/main.yaml new file mode 100644 index 000000000..ed97d539c --- /dev/null +++ b/ansible/roles/users/defaults/main.yaml @@ -0,0 +1 @@ +--- diff --git a/ansible/roles/users/library/nautobot_token.py b/ansible/roles/users/library/nautobot_token.py new file mode 100644 index 000000000..40c5b1259 --- /dev/null +++ b/ansible/roles/users/library/nautobot_token.py @@ -0,0 +1,85 @@ +from ansible.module_utils.basic import AnsibleModule +import requests + + +def get_existing_token(base_url, username, password, user_token, module): + headers = {"Accept": "application/json"} + tokens_url = f"{base_url}/api/users/tokens/" + + try: + response = requests.get(tokens_url, headers=headers, auth=(username, password)) + response.raise_for_status() + except requests.exceptions.RequestException as e: + module.fail_json( + msg=f"Failed to fetch existing tokens for user {username}: {str(e)}" + ) + + tokens = response.json().get("results", []) + return next((t for t in tokens if t.get("key") == user_token), None) + + +def create_new_token(base_url, username, password, user_token, description, module): + """Create a new Nautobot token using Basic Auth.""" + tokens_url = f"{base_url}/api/users/tokens/" + headers = {"Content-Type": "application/json", "Accept": "application/json"} + payload = {"key": user_token, "description": description, "write_enabled": True} + + try: + response = requests.post( + tokens_url, headers=headers, json=payload, auth=(username, password) + ) + response.raise_for_status() + except requests.exceptions.RequestException as e: + module.fail_json( + msg=f"Failed to create new token for user {username}: {str(e)}" + ) + + return response.json() + + +def run_module(): + module_args = dict( + base_url=dict(type="str", required=True), + username=dict(type="str", required=True), + password=dict(type="str", required=True, no_log=True), + user_token=dict(type="str", required=True, no_log=True), + token_description=dict(type="str", default="ansible-created-token"), + ) + + module = AnsibleModule(argument_spec=module_args, supports_check_mode=True) + + base_url = module.params["base_url"].rstrip("/") + username = module.params["username"] + password = module.params["password"] + user_token = module.params["user_token"] + token_description = module.params["token_description"] + + # fetch existing token + token = get_existing_token(base_url, username, password, user_token, module) + if token: + module.exit_json( + changed=False, + username=username, + message=f"Found existing Nautobot token for user {username}", + ) + + # No token found → try creating new + new_token = create_new_token( + base_url, username, password, user_token, token_description, module + ) + if not new_token: + module.fail_json(msg=f"Failed to create new token for user {username}") + + module.exit_json( + changed=True, + username=username, + message=f"No token found, created new Nautobot token for user {username}", + ) + + +def main(): + run_module() + + +if __name__ == "__main__": + main() diff --git a/ansible/roles/users/tasks/main.yaml b/ansible/roles/users/tasks/main.yaml new file mode 100644 index 000000000..51aba040f --- /dev/null +++ b/ansible/roles/users/tasks/main.yaml @@ -0,0 +1,34 @@ +--- +- name: Query user in Nautobot + ansible.builtin.uri: + url: "https://{{ nautobot_hostname }}/api/users/users/{{ nautobot_username }}" + method: GET + headers: + Authorization: "Token {{ nautobot_token }}" + return_content: true + status_code: [200, 404] + register: user_query_result + +- name: Create user in Nautobot if missing + ansible.builtin.uri: + url: "https://{{ nautobot_hostname }}/api/users/users/" + method: POST + headers: + Authorization: "Token {{ nautobot_token }}" + Content-Type: "application/json" + body_format: json + body: + username: "{{ nautobot_username }}" + password: "{{ nautobot_password }}" + is_staff: true + is_superuser: true + status_code: [201] + when: user_query_result.status == 404 + +- name: Ensure Nautobot token exists for user + nautobot_token: + base_url: "https://{{ nautobot_hostname }}" + username: "{{ nautobot_username }}" + password: "{{ nautobot_password }}" + user_token: "{{ nautobot_user_token }}" + register: nautobot_token_result diff --git a/apps/site/nautobot-site.yaml b/apps/site/nautobot-site.yaml new file mode 100644 index 000000000..cb34140e5 --- /dev/null +++ b/apps/site/nautobot-site.yaml @@ -0,0 +1,5 @@ +--- +component: nautobot +sources: + - ref: deploy + path: '{{.name}}/manifests/nautobot' diff --git a/components/nautobot/secretstore-nautobot.yaml b/components/nautobot/secretstore-nautobot.yaml index b287a4fff..c3428e823 100644 --- a/components/nautobot/secretstore-nautobot.yaml +++ b/components/nautobot/secretstore-nautobot.yaml @@ -26,6 +26,10 @@ rules: - watch resourceNames: - nautobot-superuser + - ansible-token + - openstack-token + - workflow-token + - undersync-token - apiGroups: - authorization.k8s.io resources: diff --git a/components/undersync/nautobot-token.yaml b/components/undersync/nautobot-token.yaml new file mode 100644 index 000000000..c9f7fb283 --- /dev/null +++ b/components/undersync/nautobot-token.yaml @@ -0,0 +1,28 @@ +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: nautobot-token +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: nautobot + target: + name: nautobot-token + creationPolicy: Owner + deletionPolicy: Delete + template: + engineVersion: v2 + data: + token: "{{ .token }}" + bearer_token: "Token {{ .token }}" + data: + - secretKey: token + remoteRef: + key: undersync-token + property: token + # necessary to avoid argoproj/argo-cd#13004 + conversionStrategy: Default + decodingStrategy: None + metadataPolicy: None diff --git a/workflows/argo-events/secrets/nautobot-token.yaml b/workflows/argo-events/secrets/nautobot-token.yaml index d1489093b..9c40c65eb 100644 --- a/workflows/argo-events/secrets/nautobot-token.yaml +++ b/workflows/argo-events/secrets/nautobot-token.yaml @@ -21,8 +21,8 @@ spec: data: - secretKey: token remoteRef: - key: nautobot-superuser - property: apitoken + key: workflow-token + property: token # necessary to avoid argoproj/argo-cd#13004 conversionStrategy: Default decodingStrategy: None diff --git a/workflows/kustomization.yaml b/workflows/kustomization.yaml index 2fd7e1220..610bf888a 100644 --- a/workflows/kustomization.yaml +++ b/workflows/kustomization.yaml @@ -4,3 +4,4 @@ kind: Kustomization resources: - openstack - argo-events + - nautobot diff --git a/workflows/nautobot/eventbus/eventbus-default.yaml b/workflows/nautobot/eventbus/eventbus-default.yaml new file mode 100644 index 000000000..3aedb8049 --- /dev/null +++ b/workflows/nautobot/eventbus/eventbus-default.yaml @@ -0,0 +1,14 @@ +# default NATS EventBus sourced from: +# https://raw.githubusercontent.com/argoproj/argo-events/stable/examples/eventbus/native.yaml + +apiVersion: argoproj.io/v1alpha1 +kind: EventBus +metadata: + name: default +spec: + nats: + native: + # Optional, defaults to 3. If it is < 3, set it to 3, that is the minimal requirement. + replicas: 3 + # Optional, authen strategy, "none" or "token", defaults to "none" + auth: token diff --git a/workflows/nautobot/eventbus/poddisruptionbudget-eventbus-default-pdb.yaml b/workflows/nautobot/eventbus/poddisruptionbudget-eventbus-default-pdb.yaml new file mode 100644 index 000000000..90bb443e1 --- /dev/null +++ b/workflows/nautobot/eventbus/poddisruptionbudget-eventbus-default-pdb.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: eventbus-default-pdb +spec: + maxUnavailable: 1 + selector: + matchLabels: + controller: eventbus-controller + eventbus-name: default diff --git a/workflows/nautobot/eventsources/k8s-secret-nautobot-token.yaml b/workflows/nautobot/eventsources/k8s-secret-nautobot-token.yaml new file mode 100644 index 000000000..8a1e34ad8 --- /dev/null +++ b/workflows/nautobot/eventsources/k8s-secret-nautobot-token.yaml @@ -0,0 +1,19 @@ +apiVersion: argoproj.io/v1alpha1 +kind: EventSource +metadata: + name: k8s-secret-nautobot-token +spec: + template: + serviceAccountName: k8s-events-secret-nautobot + resource: + nautobot-token-secret: + namespace: nautobot + resource: secrets + version: v1 + eventTypes: + - ADD + - UPDATE + filter: + labels: + - key: token/type + value: nautobot diff --git a/workflows/nautobot/kustomization.yaml b/workflows/nautobot/kustomization.yaml new file mode 100644 index 000000000..a38fd1d9f --- /dev/null +++ b/workflows/nautobot/kustomization.yaml @@ -0,0 +1,14 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: nautobot + +resources: + - eventbus/eventbus-default.yaml + - eventbus/poddisruptionbudget-eventbus-default-pdb.yaml + + # nautobot secret + - serviceaccounts/k8s-secret-events-nautobot.yaml + - serviceaccounts/k8s-job-create.yaml + - eventsources/k8s-secret-nautobot-token.yaml + - sensors/k8s-nautobot-secret.yaml diff --git a/workflows/nautobot/sensors/k8s-nautobot-secret.yaml b/workflows/nautobot/sensors/k8s-nautobot-secret.yaml new file mode 100644 index 000000000..52e0cd3b3 --- /dev/null +++ b/workflows/nautobot/sensors/k8s-nautobot-secret.yaml @@ -0,0 +1,71 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Sensor +metadata: + name: secret-nautobot-token + annotations: + workflows.argoproj.io/title: Nautobot Token + workflows.argoproj.io/description: |+ + Triggered when the Kubernetes Nautobot token secret is created, + this process ensures the user is created in Nautobot and a corresponding token is provisioned. +spec: + template: + serviceAccountName: k8s-job-create + dependencies: + - name: nautobot-token-secret + eventSourceName: k8s-secret-nautobot-token + eventName: nautobot-token-secret + triggers: + - template: + name: nautobot-users + k8s: + operation: create + parameters: + # Pass the body.data as JSON string into the Job environment + - src: + dependencyName: nautobot-token-secret + dataKey: body.data + transformer: + jqFilter: '@json' + dest: spec.template.spec.containers.0.env.0.value + source: + resource: + apiVersion: batch/v1 + kind: Job + metadata: + generateName: nautobot-create-token- + spec: + template: + spec: + containers: + - name: nautobot-create-token + image: ghcr.io/rackerlabs/understack/ansible:latest + imagePullPolicy: Always + command: + - "ansible-runner" + - "run" + - "/runner" + - "--playbook" + - "nautobot-user-token.yaml" + env: + - name: EXTRA_VARS + value: "" # Will be populated by the Sensor mapping + - name: NAUTOBOT_TOKEN + valueFrom: + secretKeyRef: + name: nautobot-superuser + key: apitoken + volumeMounts: + - name: ansible-inventory + mountPath: /runner/inventory/ + - name: ansible-group-vars + mountPath: /runner/inventory/group_vars/ + volumes: + - name: runner-data + emptyDir: {} + - name: ansible-inventory + configMap: + name: ansible-inventory + - name: ansible-group-vars + configMap: + name: ansible-group-vars + restartPolicy: OnFailure diff --git a/workflows/nautobot/serviceaccounts/k8s-job-create.yaml b/workflows/nautobot/serviceaccounts/k8s-job-create.yaml new file mode 100644 index 000000000..058e2198f --- /dev/null +++ b/workflows/nautobot/serviceaccounts/k8s-job-create.yaml @@ -0,0 +1,29 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: k8s-job-create + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: k8s-job +rules: + - apiGroups: ["batch"] + resources: ["jobs"] + verbs: ["create"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: k8s-job-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: k8s-job +subjects: + - kind: ServiceAccount + name: k8s-job-create + namespace: nautobot diff --git a/workflows/nautobot/serviceaccounts/k8s-secret-events-nautobot.yaml b/workflows/nautobot/serviceaccounts/k8s-secret-events-nautobot.yaml new file mode 100644 index 000000000..9fbe27740 --- /dev/null +++ b/workflows/nautobot/serviceaccounts/k8s-secret-events-nautobot.yaml @@ -0,0 +1,28 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: k8s-events-secret-nautobot +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: secret-reader +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["list", "watch"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: secret-reader-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: secret-reader +subjects: + - kind: ServiceAccount + name: k8s-events-secret-nautobot + namespace: nautobot diff --git a/workflows/openstack/secrets/nautobot-token.yaml b/workflows/openstack/secrets/nautobot-token.yaml index d4d7b2978..621ac5188 100644 --- a/workflows/openstack/secrets/nautobot-token.yaml +++ b/workflows/openstack/secrets/nautobot-token.yaml @@ -21,8 +21,8 @@ spec: data: - secretKey: token remoteRef: - key: nautobot-superuser - property: apitoken + key: openstack-token + property: token # necessary to avoid argoproj/argo-cd#13004 conversionStrategy: Default decodingStrategy: None