diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..60440bd --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "[yaml]": { + "editor.insertSpaces": true, + "editor.tabSize": 2, + "editor.detectIndentation": false + } +} diff --git a/README.md b/README.md index 225dd44..0befe13 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,174 @@ -Role Name -========= - -A brief description of the role goes here. - -Requirements ------------- - -Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required. - -Role Variables --------------- - -A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well. - -Dependencies ------------- - -A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles. - -Example Playbook ----------------- - -Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too: - - - hosts: servers - roles: - - { role: username.rolename, x: 42 } - -License -------- - -BSD - -Author Information ------------------- - -An optional section for the role authors to include contact information, or a website (HTML is not allowed). +# Ansible role: The Button + +Deploy The Button to Kubernetes with persistent game data and a TLS-enabled +Ingress. The Ingress uses cert-manager's **ClusterIssuer `ca-issuer`** by default. + +## Resources + +- Namespace (optional creation). +- PersistentVolumeClaim for `/data/data.json` and `/data/mailing_list.csv`, or an + existing claim in the application namespace. +- One-replica Deployment, with `Recreate` updates to avoid concurrent file writers. +- ClusterIP Service on port 80, forwarding to the application on port 8000. +- `networking.k8s.io/v1` Ingress routing `/` to the Service with TLS termination. + cert-manager's ingress-shim creates and renews the certificate in the TLS Secret. + +## Requirements + +- Ansible Core 2.16–2.19 on a Linux/macOS control host, or WSL on Windows. Tasks + run on the play's execution host; use `localhost` with `connection: local`. +- The `kubernetes.core` collection and its Python dependencies; see + [requirements.yml](requirements.yml) and [requirements.txt](requirements.txt). +- A reachable Kubernetes API and kubeconfig credentials authorized to manage + namespaces (unless namespace creation is disabled), PVCs, Deployments, + Services, and Ingresses. TLS certificate validation for the API stays enabled. +- A working Ingress controller, cert-manager with ingress-shim enabled, and an + existing, Ready **ClusterIssuer named `ca-issuer`** (or the issuer selected in + your annotations). The role does not install these components or create the CA. +- DNS for the chosen hostname pointing at the Ingress controller, with port 443 + reachable. Select an IngressClass explicitly if there is no cluster default. +- A default StorageClass, a named StorageClass, or a pre-provisioned PVC. The + storage driver must support writable volumes for UID/GID 10001 using `fsGroup`. +- A published container image **built from The Button application's Dockerfile**. + The role deploys an image; it does not build or push one. A plain Python base + image does not contain the application. Prefer an immutable tag or digest. + +Install dependencies on the execution host: + +```sh +python -m pip install -r requirements.txt +ansible-galaxy collection install -r requirements.yml +``` + +## Example playbook + +Place this checkout in your Ansible roles search path as `ansible-role-the-button`: + +```yaml +--- +- name: Deploy The Button + hosts: localhost + connection: local + gather_facts: false + roles: + - role: ansible-role-the-button + vars: + the_button_image: registry.example.com/the-button:1.0.0 + the_button_ingress_host: button.example.com + # Omit this when a default IngressClass is configured in the cluster. + the_button_ingress_class_name: nginx + # Controller-specific: use the equivalent configuration for your controller. + the_button_ingress_annotations: + cert-manager.io/cluster-issuer: ca-issuer + nginx.ingress.kubernetes.io/ssl-redirect: "true" +``` + +Replace the example registry and hostname with real values. With dependencies +installed, deploy directly from this checkout using the test playbook: + +```sh +ansible-playbook -i tests/inventory tests/test.yml \ + -e the_button_image=registry.example.com/the-button:1.0.0 \ + -e the_button_ingress_host=button.example.com +``` + +## Variables + +All defaults are in [defaults/main.yml](defaults/main.yml). + +| Variable | Default | Purpose | +| --- | --- | --- | +| `the_button_image` | `""` (required) | Complete published application image reference. | +| `the_button_ingress_host` | `""` (required) | DNS hostname, without scheme, port, path, or wildcard. | +| `the_button_name` | `the-button` | Resource name and instance label; at most 50 characters. | +| `the_button_namespace` | `the-button` | Namespace for all application resources. | +| `the_button_create_namespace` | `true` | Create the namespace; disable for an existing namespace. | +| `the_button_kubeconfig` | `null` | Optional kubeconfig path; otherwise use normal discovery, including `KUBECONFIG`. | +| `the_button_kube_context` | `null` | Optional kubeconfig context. | +| `the_button_wait` | `true` | Wait for the Deployment to become ready. | +| `the_button_wait_timeout` | `180` | Readiness timeout in seconds. | +| `the_button_image_pull_policy` | `IfNotPresent` | Container image pull policy. | +| `the_button_image_pull_secrets` | `[]` | Names of existing image-pull Secrets in the namespace. | +| `the_button_duration_seconds` | `86400` | Countdown duration; must be a positive integer. | +| `the_button_ingress_class_name` | `""` | IngressClass; empty omits the field and uses the cluster default. | +| `the_button_ingress_annotations` | `{"cert-manager.io/cluster-issuer": "ca-issuer"}` | Complete Ingress annotations map, including issuer and controller-specific settings. | +| `the_button_tls_secret_name` | `-tls` | Secret managed by cert-manager in the application namespace. | +| `the_button_existing_claim` | `""` | Existing PVC to mount; empty creates `-data`. | +| `the_button_storage_size` | `1Gi` | Size of the role-managed PVC. | +| `the_button_storage_class` | `null` | Omit for cluster default; `""` disables dynamic provisioning; a string selects a class. | +| `the_button_storage_access_modes` | `[ReadWriteOnce]` | Access modes for the role-managed PVC. | +| `the_button_resources` | See defaults | Requests: 100m CPU / 64Mi RAM; limits: 1 CPU / 256Mi RAM. Tune for actual load. | +| `the_button_node_selector` | `{}` | Pod node selector. | +| `the_button_tolerations` | `[]` | Pod tolerations. | +| `the_button_affinity` | `{}` | Pod affinity configuration. | + +Configure the issuer directly in `the_button_ingress_annotations`, alongside any +controller-specific annotations. Overriding this map replaces the defaults: include +`cert-manager.io/cluster-issuer: ca-issuer` to retain the default issuer, choose +another ClusterIssuer, or use `cert-manager.io/issuer` for a namespace-scoped +Issuer. Do not specify both issuer annotations. An empty map renders no annotations; +in that case, arrange certificate provisioning separately for the TLS Secret. + +## TLS and security + +- TLS terminates at the Ingress; traffic from the controller to the Pod is HTTP. + Configure HTTP-to-HTTPS redirects, HSTS, modern TLS policy, rate limiting, and + request-size limits on your Ingress controller as appropriate. Redirect + annotations differ between controllers and are not imposed by this role. +- Certificates from a private CA are **not automatically trusted by browsers**. + Distribute the CA trust chain to clients; do not bypass certificate validation. + cert-manager owns private-key generation, certificate issuance, and renewal; + no private keys or credentials are hardcoded in the role. +- Readiness waiting covers the Deployment, **not** certificate issuance or DNS + propagation. Confirm the generated Certificate is Ready and the HTTPS endpoint + presents a valid certificate for the configured hostname before exposing it. +- The container runs as non-root UID/GID 10001, drops all capabilities, disallows + privilege escalation, uses `RuntimeDefault` seccomp, and has a read-only root + filesystem. Only `/data` and a bounded `/tmp` volume are writable. Kubernetes + service-account credentials are not mounted. Health probes use `/api/state`. +- The Service is internal only. Apply cluster-specific NetworkPolicies to allow + your Ingress controller to reach TCP/8000 and restrict other traffic. The role + does not assume controller namespace labels or manage cluster network policy. +- Use an encrypted StorageClass and encrypted, access-controlled backups: the + volume contains password hashes, session hashes, and mailing-list personal data. + Restrict access to the namespace and TLS/registry Secrets through cluster RBAC. + +## Persistence and updates + +**Do not scale this Deployment beyond one replica or attach its PVC to another +running application instance.** The application caches state in memory and writes +local files; a shared filesystem does not make multiple replicas safe. + +Re-running the role applies resource changes idempotently. Updates intentionally +incur brief downtime because the previous Pod must stop before its replacement +starts. There is no autoscaler. Back up `/data` before upgrades and test restores. +Changing the countdown setting does not promise to reset an existing saved game. + +PVC creation does not wait for binding before creating the Deployment, allowing +StorageClasses with `WaitForFirstConsumer`. Existing claims are not modified. +Do not change immutable PVC fields on an existing claim. Expansion requires a +StorageClass that permits it. Changing the resource name or namespace creates new +resources; it does not migrate data or clean up the old installation. + +The role has no uninstall operation and does not delete claims. Manually deleting +the namespace or PVC can destroy game data according to the PV reclaim policy. + +## Tests + +Cluster-free render checks exercise default and custom settings, TLS wiring, +labels and ports, storage modes, existing-claim selection, and container hardening: + +```sh +ansible-playbook -i tests/inventory tests/render.yml +ansible-playbook -i tests/inventory tests/test.yml --syntax-check +``` + +[tests/test.yml](tests/test.yml) is an integration deployment playbook: supply the +required variables and a test-cluster kubeconfig as shown above. Run it twice to +check idempotency, then verify HTTPS, certificate readiness, game operations, and +data persistence across a Pod replacement. These integration checks require a +real cluster and are not performed by the cluster-free tests. + +## License + +MIT-0 (matching the role's SPDX headers). diff --git a/defaults/main.yml b/defaults/main.yml index 32af6d4..3d1dcaa 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -1,3 +1,45 @@ -#SPDX-License-Identifier: MIT-0 ---- -# defaults file for ansible-role-the-button +# SPDX-License-Identifier: MIT-0 +--- +# Required: a published image built from The Button's Dockerfile and a DNS name. +the_button_image: "" +the_button_ingress_host: "" + +the_button_name: the-button +the_button_namespace: the-button +the_button_create_namespace: true + +# Leave unset to use the controller's normal kubeconfig discovery (including KUBECONFIG). +the_button_kubeconfig: null +the_button_kube_context: null +the_button_wait: true +the_button_wait_timeout: 180 + +the_button_image_pull_policy: IfNotPresent +# Names of existing registry Secrets in the application namespace. +the_button_image_pull_secrets: [] +the_button_duration_seconds: 86400 + +# Keep this empty to use the cluster's default IngressClass. +the_button_ingress_class_name: "" +the_button_ingress_annotations: + cert-manager.io/cluster-issuer: ca-issuer +the_button_tls_secret_name: "{{ the_button_name }}-tls" + +# The application uses local files, so persistence and a single writer are mandatory. +the_button_existing_claim: "" +the_button_storage_size: 1Gi +# null: cluster default; "": no StorageClass; otherwise: named StorageClass. +the_button_storage_class: null +the_button_storage_access_modes: + - ReadWriteOnce + +the_button_resources: + requests: + cpu: 100m + memory: 64Mi + limits: + cpu: "1" + memory: 256Mi +the_button_node_selector: {} +the_button_tolerations: [] +the_button_affinity: {} diff --git a/handlers/main.yml b/handlers/main.yml index e48aa8f..c07d5f1 100644 --- a/handlers/main.yml +++ b/handlers/main.yml @@ -1,3 +1,3 @@ -#SPDX-License-Identifier: MIT-0 ---- -# handlers file for ansible-role-the-button +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for ansible-role-the-button diff --git a/meta/main.yml b/meta/main.yml index 36b9858..6f52b20 100644 --- a/meta/main.yml +++ b/meta/main.yml @@ -1,35 +1,14 @@ -#SPDX-License-Identifier: MIT-0 -galaxy_info: - author: your name - description: your role description - company: your company (optional) - - # If the issue tracker for your role is not on github, uncomment the - # next line and provide a value - # issue_tracker_url: http://example.com/issue/tracker - - # Choose a valid license ID from https://spdx.org - some suggested licenses: - # - BSD-3-Clause (default) - # - MIT - # - GPL-2.0-or-later - # - GPL-3.0-only - # - Apache-2.0 - # - CC-BY-4.0 - license: license (GPL-2.0-or-later, MIT, etc) - - min_ansible_version: 2.1 - - # If this a Container Enabled role, provide the minimum Ansible Container version. - # min_ansible_container_version: - - galaxy_tags: [] - # List tags for your role here, one per line. A tag is a keyword that describes - # and categorizes the role. Users find roles by searching for tags. Be sure to - # remove the '[]' above, if you add tags to this list. - # - # NOTE: A tag is limited to a single word comprised of alphanumeric characters. - # Maximum 20 tags per role. - -dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. +# SPDX-License-Identifier: MIT-0 +--- +galaxy_info: + role_name: the_button + author: Eric Meehan + description: Deploy The Button to Kubernetes with persistent storage and cert-manager TLS ingress. + license: MIT-0 + min_ansible_version: "2.16" + galaxy_tags: + - kubernetes + - ingress + - certmanager + - games +dependencies: [] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..aa9547d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +# Install on the Ansible execution host (Linux/macOS or WSL), not in the app image. +ansible-core>=2.16,<2.20 +kubernetes>=24.2.0,<35.0.0 +PyYAML>=6.0.1,<7.0.0 +jsonpatch>=1.33,<2.0 diff --git a/requirements.yml b/requirements.yml new file mode 100644 index 0000000..6824101 --- /dev/null +++ b/requirements.yml @@ -0,0 +1,4 @@ +--- +collections: + - name: kubernetes.core + version: ">=3.0.0,<7.0.0" diff --git a/tasks/main.yml b/tasks/main.yml index a5b1954..55f24b5 100644 --- a/tasks/main.yml +++ b/tasks/main.yml @@ -1,3 +1,62 @@ -#SPDX-License-Identifier: MIT-0 ---- -# tasks file for ansible-role-the-button +# SPDX-License-Identifier: MIT-0 +--- +- name: Validate required deployment settings + ansible.builtin.assert: + that: + - the_button_image is string and the_button_image | length > 0 + - the_button_ingress_host is string and the_button_ingress_host | length > 0 + - the_button_ingress_host | length <= 253 + - the_button_ingress_host is match('^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$') + - the_button_name is match('^[a-z]([a-z0-9-]*[a-z0-9])?$') + - the_button_name | length <= 50 + - the_button_namespace is match('^[a-z0-9]([a-z0-9-]*[a-z0-9])?$') + - the_button_namespace | length <= 63 + - the_button_tls_secret_name | length > 0 + - the_button_duration_seconds | string is match('^[1-9][0-9]*$') + - the_button_image_pull_policy in ['Always', 'IfNotPresent', 'Never'] + - the_button_ingress_annotations is mapping + - the_button_resources is mapping + - the_button_storage_access_modes | length > 0 + - the_button_wait_timeout | int > 0 + fail_msg: >- + Set the_button_image to a published application image and the_button_ingress_host + to a DNS hostname (no scheme or path). Names must be valid Kubernetes names, + and duration and wait timeout must be positive. + +- name: Create the application namespace + kubernetes.core.k8s: + kubeconfig: "{{ the_button_kubeconfig | default(omit, true) }}" + context: "{{ the_button_kube_context | default(omit, true) }}" + state: present + apply: true + definition: + apiVersion: v1 + kind: Namespace + metadata: + name: "{{ the_button_namespace }}" + when: the_button_create_namespace | bool + +- name: Create persistent storage for game data and mailing list + kubernetes.core.k8s: + kubeconfig: "{{ the_button_kubeconfig | default(omit, true) }}" + context: "{{ the_button_kube_context | default(omit, true) }}" + state: present + apply: true + definition: "{{ lookup('ansible.builtin.template', 'pvc.yml.j2') | from_yaml }}" + # Do not wait here: WaitForFirstConsumer volumes need the Deployment first. + wait: false + when: the_button_existing_claim | length == 0 + +- name: Deploy The Button service, application, and TLS ingress + kubernetes.core.k8s: + kubeconfig: "{{ the_button_kubeconfig | default(omit, true) }}" + context: "{{ the_button_kube_context | default(omit, true) }}" + state: present + apply: true + definition: "{{ lookup('ansible.builtin.template', item) | from_yaml }}" + wait: "{{ the_button_wait | bool }}" + wait_timeout: "{{ the_button_wait_timeout | int }}" + loop: + - service.yml.j2 + - deployment.yml.j2 + - ingress.yml.j2 diff --git a/templates/deployment.yml.j2 b/templates/deployment.yml.j2 new file mode 100644 index 0000000..7c8f7b2 --- /dev/null +++ b/templates/deployment.yml.j2 @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: MIT-0 +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ the_button_name | to_json }} + namespace: {{ the_button_namespace | to_json }} + labels: + app.kubernetes.io/name: the-button + app.kubernetes.io/instance: {{ the_button_name | to_json }} + app.kubernetes.io/managed-by: ansible +spec: + # State is cached in-process and persisted to files; never run concurrent replicas. + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: the-button + app.kubernetes.io/instance: {{ the_button_name | to_json }} + template: + metadata: + labels: + app.kubernetes.io/name: the-button + app.kubernetes.io/instance: {{ the_button_name | to_json }} + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + terminationGracePeriodSeconds: 30 +{% if the_button_image_pull_secrets | length > 0 %} + imagePullSecrets: +{% for secret in the_button_image_pull_secrets %} + - name: {{ secret | to_json }} +{% endfor %} +{% endif %} + nodeSelector: {{ the_button_node_selector | to_json }} + tolerations: {{ the_button_tolerations | to_json }} + affinity: {{ the_button_affinity | to_json }} + containers: + - name: the-button + image: {{ the_button_image | to_json }} + imagePullPolicy: {{ the_button_image_pull_policy | to_json }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + ports: + - name: http + containerPort: 8000 + protocol: TCP + env: + - name: BUTTON_HOST + value: "0.0.0.0" + - name: BUTTON_PORT + value: "8000" + - name: BUTTON_DURATION_SECONDS + value: {{ (the_button_duration_seconds | string) | to_json }} + - name: BUTTON_DATA_FILE + value: /data/data.json + - name: BUTTON_MAILING_LIST_FILE + value: /data/mailing_list.csv + - name: PYTHONDONTWRITEBYTECODE + value: "1" + resources: {{ the_button_resources | to_json }} + startupProbe: + httpGet: + path: /api/state + port: http + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 30 + readinessProbe: + httpGet: + path: /api/state + port: http + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /api/state + port: http + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 3 + volumeMounts: + - name: data + mountPath: /data + - name: tmp + mountPath: /tmp + volumes: + - name: data + persistentVolumeClaim: + claimName: {{ (the_button_existing_claim or (the_button_name ~ '-data')) | to_json }} + - name: tmp + emptyDir: + sizeLimit: 64Mi diff --git a/templates/ingress.yml.j2 b/templates/ingress.yml.j2 new file mode 100644 index 0000000..2ef1e43 --- /dev/null +++ b/templates/ingress.yml.j2 @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: MIT-0 +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ the_button_name | to_json }} + namespace: {{ the_button_namespace | to_json }} + labels: + app.kubernetes.io/name: the-button + app.kubernetes.io/instance: {{ the_button_name | to_json }} + app.kubernetes.io/managed-by: ansible +{% if the_button_ingress_annotations %} + annotations: +{% for key, value in the_button_ingress_annotations.items() %} + {{ key | to_json }}: {{ (value | string) | to_json }} +{% endfor %} +{% else %} + annotations: {} +{% endif %} +spec: +{% if the_button_ingress_class_name | length > 0 %} + ingressClassName: {{ the_button_ingress_class_name | to_json }} +{% endif %} + tls: + - hosts: + - {{ the_button_ingress_host | to_json }} + secretName: {{ the_button_tls_secret_name | to_json }} + rules: + - host: {{ the_button_ingress_host | to_json }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ the_button_name | to_json }} + port: + name: http diff --git a/templates/pvc.yml.j2 b/templates/pvc.yml.j2 new file mode 100644 index 0000000..fea2426 --- /dev/null +++ b/templates/pvc.yml.j2 @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: MIT-0 +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ (the_button_name ~ '-data') | to_json }} + namespace: {{ the_button_namespace | to_json }} + labels: + app.kubernetes.io/name: the-button + app.kubernetes.io/instance: {{ the_button_name | to_json }} + app.kubernetes.io/managed-by: ansible +spec: + accessModes: {{ the_button_storage_access_modes | to_json }} +{% if the_button_storage_class is not none %} + storageClassName: {{ the_button_storage_class | to_json }} +{% endif %} + resources: + requests: + storage: {{ the_button_storage_size | to_json }} diff --git a/templates/service.yml.j2 b/templates/service.yml.j2 new file mode 100644 index 0000000..c8b7528 --- /dev/null +++ b/templates/service.yml.j2 @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: MIT-0 +apiVersion: v1 +kind: Service +metadata: + name: {{ the_button_name | to_json }} + namespace: {{ the_button_namespace | to_json }} + labels: + app.kubernetes.io/name: the-button + app.kubernetes.io/instance: {{ the_button_name | to_json }} + app.kubernetes.io/managed-by: ansible +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: the-button + app.kubernetes.io/instance: {{ the_button_name | to_json }} + ports: + - name: http + port: 80 + targetPort: http + protocol: TCP diff --git a/tests/inventory b/tests/inventory index 03ca42f..abfffbf 100644 --- a/tests/inventory +++ b/tests/inventory @@ -1,3 +1,3 @@ -#SPDX-License-Identifier: MIT-0 -localhost - +#SPDX-License-Identifier: MIT-0 +localhost + diff --git a/tests/render.yml b/tests/render.yml new file mode 100644 index 0000000..8ca7ce7 --- /dev/null +++ b/tests/render.yml @@ -0,0 +1,150 @@ +# SPDX-License-Identifier: MIT-0 +--- +- name: Validate rendered Kubernetes resources without a cluster + hosts: localhost + connection: local + gather_facts: false + vars: + template_directory: "{{ playbook_dir }}/../templates" + tasks: + - name: Load the role defaults + ansible.builtin.include_vars: + file: "{{ playbook_dir }}/../defaults/main.yml" + + - name: Set required test inputs + ansible.builtin.set_fact: + the_button_image: registry.example.com/the-button:1.0.0 + the_button_ingress_host: button.example.com + + - name: Render default manifests using Ansible's template engine + ansible.builtin.set_fact: + deployment: "{{ lookup('ansible.builtin.template', template_directory ~ '/deployment.yml.j2') | from_yaml }}" + service: "{{ lookup('ansible.builtin.template', template_directory ~ '/service.yml.j2') | from_yaml }}" + ingress: "{{ lookup('ansible.builtin.template', template_directory ~ '/ingress.yml.j2') | from_yaml }}" + pvc: "{{ lookup('ansible.builtin.template', template_directory ~ '/pvc.yml.j2') | from_yaml }}" + + - name: Check TLS routing, persistent storage, and single-writer invariants + ansible.builtin.assert: + that: + - deployment.apiVersion == 'apps/v1' + - deployment.spec.replicas == 1 + - deployment.spec.strategy.type == 'Recreate' + - deployment.spec.selector.matchLabels == service.spec.selector + - deployment.spec.template.metadata.labels == service.spec.selector + - deployment.spec.template.spec.volumes[0].persistentVolumeClaim.claimName == pvc.metadata.name + - deployment.spec.template.spec.containers[0].volumeMounts[0].mountPath == '/data' + - deployment.spec.template.spec.containers[0].image == the_button_image + - deployment.spec.template.spec.containers[0].ports[0].containerPort == 8000 + - service.spec.type == 'ClusterIP' + - service.spec.ports[0].port == 80 + - service.spec.ports[0].targetPort == 'http' + - ingress.apiVersion == 'networking.k8s.io/v1' + - ingress.metadata.annotations['cert-manager.io/cluster-issuer'] == 'ca-issuer' + - ingress.spec.tls[0].hosts == [the_button_ingress_host] + - ingress.spec.tls[0].secretName == 'the-button-tls' + - ingress.spec.rules[0].host == the_button_ingress_host + - ingress.spec.rules[0].http.paths[0].backend.service.name == service.metadata.name + - ingress.spec.rules[0].http.paths[0].backend.service.port.name == 'http' + - ingress.spec.rules[0].http.paths[0].pathType == 'Prefix' + - "'ingressClassName' not in ingress.spec" + - "'storageClassName' not in pvc.spec" + - pvc.spec.accessModes == ['ReadWriteOnce'] + - pvc.spec.resources.requests.storage == '1Gi' + + - name: Check container hardening and health probes + ansible.builtin.assert: + that: + - not deployment.spec.template.spec.automountServiceAccountToken + - deployment.spec.template.spec.securityContext.runAsNonRoot + - deployment.spec.template.spec.securityContext.runAsUser == 10001 + - deployment.spec.template.spec.securityContext.fsGroup == 10001 + - deployment.spec.template.spec.securityContext.seccompProfile.type == 'RuntimeDefault' + - not deployment.spec.template.spec.containers[0].securityContext.allowPrivilegeEscalation + - deployment.spec.template.spec.containers[0].securityContext.readOnlyRootFilesystem + - deployment.spec.template.spec.containers[0].securityContext.capabilities.drop == ['ALL'] + - deployment.spec.template.spec.containers[0].startupProbe.httpGet.path == '/api/state' + - deployment.spec.template.spec.containers[0].readinessProbe.httpGet.port == 'http' + - deployment.spec.template.spec.containers[0].livenessProbe.httpGet.port == 'http' + + - name: Configure custom deployment settings + ansible.builtin.set_fact: + the_button_name: custom-button + the_button_namespace: games + the_button_ingress_class_name: nginx + the_button_ingress_annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "true" + cert-manager.io/cluster-issuer: custom-ca-issuer + the_button_existing_claim: restored-game-data + the_button_storage_class: fast + the_button_image_pull_secrets: + - private-registry + the_button_duration_seconds: 60 + the_button_node_selector: + kubernetes.io/os: linux + the_button_tolerations: + - key: games + operator: Exists + effect: NoSchedule + + - name: Render custom manifests + ansible.builtin.set_fact: + deployment: "{{ lookup('ansible.builtin.template', template_directory ~ '/deployment.yml.j2') | from_yaml }}" + ingress: "{{ lookup('ansible.builtin.template', template_directory ~ '/ingress.yml.j2') | from_yaml }}" + pvc: "{{ lookup('ansible.builtin.template', template_directory ~ '/pvc.yml.j2') | from_yaml }}" + + - name: Check configurable settings and custom annotations + ansible.builtin.assert: + that: + - deployment.metadata.name == 'custom-button' + - deployment.metadata.namespace == 'games' + - deployment.spec.template.spec.volumes[0].persistentVolumeClaim.claimName == 'restored-game-data' + - deployment.spec.template.spec.imagePullSecrets == [{'name':'private-registry'}] + - deployment.spec.template.spec.nodeSelector['kubernetes.io/os'] == 'linux' + - deployment.spec.template.spec.tolerations[0].key == 'games' + - deployment.spec.template.spec.containers[0].env[2].value == '60' + - ingress.spec.ingressClassName == 'nginx' + - ingress.metadata.annotations['nginx.ingress.kubernetes.io/ssl-redirect'] == 'true' + - ingress.metadata.annotations['cert-manager.io/cluster-issuer'] == 'custom-ca-issuer' + - ingress.spec.tls[0].secretName == 'custom-button-tls' + - pvc.spec.storageClassName == 'fast' + + - name: Configure a namespace-scoped issuer through annotations + ansible.builtin.set_fact: + the_button_ingress_annotations: + cert-manager.io/issuer: namespace-issuer + + - name: Render ingress with a namespace-scoped issuer + ansible.builtin.set_fact: + ingress: "{{ lookup('ansible.builtin.template', template_directory ~ '/ingress.yml.j2') | from_yaml }}" + + - name: Check issuer annotations are rendered without injected defaults + ansible.builtin.assert: + that: + - ingress.metadata.annotations == the_button_ingress_annotations + - "'cert-manager.io/cluster-issuer' not in ingress.metadata.annotations" + + - name: Configure an empty annotations map + ansible.builtin.set_fact: + the_button_ingress_annotations: {} + + - name: Render ingress without annotations + ansible.builtin.set_fact: + ingress: "{{ lookup('ansible.builtin.template', template_directory ~ '/ingress.yml.j2') | from_yaml }}" + + - name: Check annotations can be cleared entirely + ansible.builtin.assert: + that: + - ingress.metadata.annotations == {} + + - name: Disable dynamic provisioning + ansible.builtin.set_fact: + the_button_storage_class: "" + + - name: Render a claim with dynamic provisioning disabled + ansible.builtin.set_fact: + static_pvc: "{{ lookup('ansible.builtin.template', template_directory ~ '/pvc.yml.j2') | from_yaml }}" + + - name: Check explicit empty StorageClass is preserved + ansible.builtin.assert: + that: + - static_pvc.spec.storageClassName == '' diff --git a/tests/test.yml b/tests/test.yml index 798ca85..e3d69ba 100644 --- a/tests/test.yml +++ b/tests/test.yml @@ -1,6 +1,10 @@ -#SPDX-License-Identifier: MIT-0 ---- -- hosts: localhost - remote_user: root - roles: - - ansible-role-the-button +# SPDX-License-Identifier: MIT-0 +--- +- name: Deploy The Button to a test cluster + hosts: localhost + connection: local + gather_facts: false + tasks: + - name: Run the role from this checkout + ansible.builtin.include_role: + name: "{{ playbook_dir }}/.." diff --git a/vars/main.yml b/vars/main.yml index 5bbf167..abeac72 100644 --- a/vars/main.yml +++ b/vars/main.yml @@ -1,3 +1,3 @@ -#SPDX-License-Identifier: MIT-0 ---- -# vars file for ansible-role-the-button +#SPDX-License-Identifier: MIT-0 +--- +# vars file for ansible-role-the-button