From e41daa769a1b5841e80e5d8a5eb08d3d68daaf06 Mon Sep 17 00:00:00 2001 From: NWarila <33955773+NWarila@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:51:43 +0000 Subject: [PATCH 1/5] feat: adopt golden pins and workflows from pdq-deploy-inventory Normalizes this repo onto the Golden Repo layout: root .framework-pin + .github/terraform-framework-pin at the golden SHAs, the golden aws-deploy skeleton (converge + hold + teardown; app-specific proofs stripped), the thin powershell.yml caller, the fleet quality gate with role discovery, and the quality/compose support files. --- .editorconfig | 53 +++ .framework-pin | 1 + .github/terraform-framework-pin | 1 + .github/workflows/aws-deploy.yml | 510 ++++++++++++++++++++++++++++ .github/workflows/powershell.yml | 48 +++ .github/workflows/quality.yml | 109 ++++++ .gitignore | 44 +++ .yamllint.yml | 86 +++++ quality-tools.env | 9 + requirements-quality.txt | 41 +++ requirements-quality.yml | 12 + scripts/compose-and-run.sh | 207 +++++++++++ scripts/materialize-role-scripts.sh | 87 +++++ 13 files changed, 1208 insertions(+) create mode 100644 .editorconfig create mode 100644 .framework-pin create mode 100644 .github/terraform-framework-pin create mode 100644 .github/workflows/aws-deploy.yml create mode 100644 .github/workflows/powershell.yml create mode 100644 .github/workflows/quality.yml create mode 100644 .gitignore create mode 100644 .yamllint.yml create mode 100644 quality-tools.env create mode 100644 requirements-quality.txt create mode 100644 requirements-quality.yml create mode 100755 scripts/compose-and-run.sh create mode 100755 scripts/materialize-role-scripts.sh diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..769c7d3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,53 @@ +# ============================================================================= +# EditorConfig — https://editorconfig.org +# ============================================================================= +# Enforces consistent formatting across all editors without requiring plugin +# configuration. Supported natively in VS Code, JetBrains, Vim, Emacs, etc. +# ============================================================================= + +root = true + +# --------------------------------------------------------------------------- +# All files +# --------------------------------------------------------------------------- +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +# --------------------------------------------------------------------------- +# YAML (Ansible playbooks, roles, vars) +# --------------------------------------------------------------------------- +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +# --------------------------------------------------------------------------- +# Jinja2 templates +# --------------------------------------------------------------------------- +[*.j2] +indent_style = space +indent_size = 2 + +# --------------------------------------------------------------------------- +# Markdown — preserve trailing spaces (line breaks) but trim blank lines +# --------------------------------------------------------------------------- +[*.md] +trim_trailing_whitespace = false +max_line_length = off + +# --------------------------------------------------------------------------- +# JSON (release-please manifest, pre-commit config) +# --------------------------------------------------------------------------- +[*.json] +indent_style = space +indent_size = 2 + +# --------------------------------------------------------------------------- +# Makefile — tabs required +# --------------------------------------------------------------------------- +[Makefile] +indent_style = tab diff --git a/.framework-pin b/.framework-pin new file mode 100644 index 0000000..86a9d21 --- /dev/null +++ b/.framework-pin @@ -0,0 +1 @@ +363c5b4cd7555bb0a80ba8cbe6f7c72418d267b4 diff --git a/.github/terraform-framework-pin b/.github/terraform-framework-pin new file mode 100644 index 0000000..5b0ae89 --- /dev/null +++ b/.github/terraform-framework-pin @@ -0,0 +1 @@ +546d80b1ea75d4a98870bbbb137e5ca329b33697 diff --git a/.github/workflows/aws-deploy.yml b/.github/workflows/aws-deploy.yml new file mode 100644 index 0000000..cb695f2 --- /dev/null +++ b/.github/workflows/aws-deploy.yml @@ -0,0 +1,510 @@ +# =========================================================================================== # +# AWS Deploy — gitlab +# ------------------------------------------------------------------------------------------- # +# Ephemeral lifecycle: terraform init/apply -> ansible -> terraform destroy on the normal path. +# Terraform is DATA-ONLY here (a plain tfvars, no .tf files); resources live in the pinned +# aws-terraform-framework, configuration in the pinned ansible-framework + this repo's role. +# +# * AWS credentials are issued only to this workflow at refs/heads/main. Pull requests use +# the credential-free quality workflow; feature-branch workflow code never reaches AWS. +# * Reachability: direct SSH to a subnet-assigned public IPv4. At runtime the framework adds +# one run-scoped security group to every interface, sourced from the runner's validated +# public IPv4 and, when an operator hostname is configured, from the address it resolves to +# as well — that one adding RDP, so a person can work on a held host. This configuration +# creates no EIP and has no NAT. The playbook performs the bounded direct-SSH readiness +# check because readiness_gate remains disabled. +# * AWS credentials are exchanged before provisioning and refreshed before eligible teardown. +# The initial 7,800-second session covers normal deployment work; the teardown refresh uses a +# modest 3,600-second session so long holds do not reach destroy with expired credentials. +# The 330-minute job budget includes a four-hour hold plus measured-path margin and stays below +# the runner's 360-minute execution limit. It is not a teardown guarantee: abnormal delay can +# exhaust the job budget before destroy and strand paid resources. That risk is accepted for +# this temporary proof bed. +# * One fixed state key; this workflow's concurrency group prevents overlapping applies and +# never cancels a live lifecycle. A later lifecycle can clean predecessor state before apply; +# no age-gated reaper exists for a run killed before destroy. +# * Protected-main pushes prove immediately; an off-minute weekly schedule proves recurrence. +# One durable incident is updated on failure and closed after the next successful lifecycle. +# =========================================================================================== # +name: AWS Deploy + +'on': + workflow_dispatch: + inputs: + hold_minutes: + description: Hold the converged guest before teardown + type: choice + options: [ '0', '15', '60', '120', '240' ] + default: '0' + push: + branches: [ main ] + schedule: + # Avoid the top-of-hour load spike documented for scheduled Actions workflows. + - cron: '17 9 * * 1' +permissions: {} + +# One boundary, one state key — never two runs at once, never cancel a live apply. +concurrency: + group: aws-deploy-${{ github.repository }} + cancel-in-progress: false + +jobs: + main-ref: + name: Protected main ref + runs-on: ubuntu-24.04 + timeout-minutes: 2 + permissions: {} + steps: + - name: Refuse privileged execution outside protected main + env: + EXPECTED_REF: refs/heads/main + run: | + set -euo pipefail + if [ "${GITHUB_REF}" != "${EXPECTED_REF}" ]; then + echo "::error::AWS lifecycle runs only from ${EXPECTED_REF}; got ${GITHUB_REF}." + exit 1 + fi + + aws-deploy: + name: AWS (ephemeral deploy-test-destroy) + needs: main-ref + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + # The 330-minute budget leaves measured-path margin around a 240-minute hold and remains below + # the runner's 360-minute execution limit; a timeout can still strand resources before teardown. + timeout-minutes: 330 + permissions: + id-token: write + contents: read + env: + AWS_REGION: us-east-1 + DEPLOY_ROLE: nwarila-platform_gitlab_runner + # Must match key_name in terraform/aws.tfvars. + CI_KEY_NAME: nwarila-ec2-key + + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Validate hold duration + if: github.event_name == 'workflow_dispatch' + env: + HOLD_MINUTES: ${{ inputs.hold_minutes }} + run: | + set -euo pipefail + case "${HOLD_MINUTES}" in + 0|15|60|120|240) ;; + *) + echo "::error::hold_minutes must be one of 0, 15, 60, 120, or 240; re-dispatch with a listed choice." + exit 1 + ;; + esac + + - name: Set up pinned Python + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + with: + python-version: '3.12.11' + cache: pip + cache-dependency-path: requirements-quality.txt + + - name: Install Ansible + env: + PIP_DISABLE_PIP_VERSION_CHECK: '1' + run: | + set -euo pipefail + . ./quality-tools.env + runtime_venv="${RUNNER_TEMP}/aws-proof-venv" + python3 -m venv "${runtime_venv}" + "${runtime_venv}/bin/python" -m pip install --quiet --no-cache-dir \ + --requirement requirements-quality.txt + "${runtime_venv}/bin/ansible-galaxy" collection install --force \ + --requirements-file requirements-quality.yml + echo "${runtime_venv}/bin" >> "${GITHUB_PATH}" + + # The framework consumes a pre-existing key pair and never creates one, so the standing + # account key pair named in terraform/aws.tfvars is the launch key. Windows user_data + # installs its public half by reading IMDS; only the private half is secret, and it + # exists solely in this runner's temporary directory, mode 0600, for this one job. + - name: Stage the launch private key + env: + SSH_PRIVATE_KEY: ${{ secrets.AWS_EC2_SSH_PRIVATE_KEY }} + run: | + set -euo pipefail + private_key="${RUNNER_TEMP}/${CI_KEY_NAME}.pem" + umask 077 + printf '%s\n' "${SSH_PRIVATE_KEY}" > "${private_key}" + staged_public="$(ssh-keygen -y -f "${private_key}")" \ + || { echo "::error::AWS_EC2_SSH_PRIVATE_KEY is not a usable private key."; exit 1; } + echo "CI_PRIVATE_KEY=${private_key}" >> "${GITHUB_ENV}" + echo "CI_STAGED_PUBLIC_KEY=${staged_public}" >> "${GITHUB_ENV}" + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ env.DEPLOY_ROLE }} + aws-region: ${{ env.AWS_REGION }} + allowed-account-ids: ${{ secrets.AWS_ACCOUNT_ID }} + role-session-name: aws-deploy-${{ github.run_id }} + role-duration-seconds: 7800 + + # actions/checkout cannot read a file into ref:, so validated immutable SHAs pass through + # step outputs. Reject malformed/multiline pins before any external framework checkout. + # A rotated secret that no longer matches the launch key pair is invisible until Ansible + # cannot log in — which surfaces fifteen minutes and one instance later as a readiness + # timeout. Compare the two public halves here instead, before anything is created. + - name: Assert the staged key matches the launch key pair + run: | + set -euo pipefail + launch_public="$(aws ec2 describe-key-pairs --key-names "${CI_KEY_NAME}" \ + --include-public-key --query 'KeyPairs[0].PublicKey' --output text)" + staged_material="$(awk '{print $1" "$2}' <<< "${CI_STAGED_PUBLIC_KEY}")" + launch_material="$(awk '{print $1" "$2}' <<< "${launch_public}")" + if [ "${staged_material}" != "${launch_material}" ]; then + echo "::error::AWS_EC2_SSH_PRIVATE_KEY does not match EC2 key pair ${CI_KEY_NAME}." \ + "The guest installs the key pair's public half, so SSH would fail after launch." \ + "Rotate the secret to the matching private key, or repoint key_name." + exit 1 + fi + echo "staged key matches ${CI_KEY_NAME}" + + - name: Read framework pins + id: pins + run: | + set -euo pipefail + emit_pin() { + local name="$1" path="$2" pin + local -a lines + mapfile -t lines < "${path}" + [ "${#lines[@]}" -eq 1 ] || { + echo "${path} must contain exactly one line" >&2 + return 1 + } + pin="${lines[0]}" + [[ "${pin}" =~ ^[0-9a-f]{40}$ ]] || { + echo "${path} must contain exactly one lowercase 40-character Git SHA" >&2 + return 1 + } + printf '%s=%s\n' "${name}" "${pin}" >> "${GITHUB_OUTPUT}" + } + # The ansible-framework pin is the single root .framework-pin, read by BOTH this workflow + # and scripts/compose-and-run.sh, so local and CI can never diverge. The terraform pin + # stays under .github/ because only CI consumes terraform. + emit_pin terraform .github/terraform-framework-pin + emit_pin ansible .framework-pin + + - name: Checkout aws-terraform-framework (pinned) + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + repository: ${{ github.repository_owner }}/aws-terraform-framework + ref: ${{ steps.pins.outputs.terraform }} + path: .frameworks/aws-terraform-framework + persist-credentials: false + + - name: Checkout ansible-framework (pinned) + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + repository: ${{ github.repository_owner }}/ansible-framework + ref: ${{ steps.pins.outputs.ansible }} + path: .frameworks/ansible-framework + persist-credentials: false + + - name: Setup Terraform + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: '1.15.1' + terraform_wrapper: false + + - name: Discover and validate runner public IPv4 + run: | + set -euo pipefail + runner_ip="$(curl -fsS https://checkip.amazonaws.com)" + ipv4_octet='(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])' + if [[ ! "${runner_ip}" =~ ^${ipv4_octet}\.${ipv4_octet}\.${ipv4_octet}\.${ipv4_octet}$ ]]; then + echo "::error::Runner public address discovery did not return a bare IPv4 address." + exit 1 + fi + echo "::add-mask::${runner_ip}" + echo "RUNNER_IP=${runner_ip}" >> "${GITHUB_ENV}" + echo 'runner public IPv4 validated' + + # The operator's own address, so a held host can be reached directly rather than through a + # tunnel. The secret carries a NAME, not an address: the name is stable and private, the + # address behind it changes with the operator's connection, and resolving here means only + # the address reaches Terraform -- masked in the log, exactly as the runner's is. The + # address does reach Terraform state and the security group it builds; what no commit + # records is the operator it belongs to. + # + # Absent secret means no operator access, not a failed run: the framework's debug_ip is + # optional and a pipeline with nobody watching should grant nobody anything. A name that + # is set and will not resolve DOES fail, because the alternative is a host that silently + # cannot be reached by the person who asked for it. + - name: Resolve the operator address for direct access + env: + DEBUG_HOSTNAME: ${{ secrets.AWS_DEBUG_HOSTNAME }} + run: | + set -euo pipefail + if [ -z "${DEBUG_HOSTNAME}" ]; then + echo 'no operator hostname configured; this run grants no direct access' + exit 0 + fi + # `|| true` so the check below is the ONE gate: a name that does not resolve and a name + # that resolves to something that is not an address fail the same way to a reader, and + # without it the first kills the step on pipefail before this message can explain + # itself. The check proves the SHAPE of an address, not that it is routable -- a name + # pointing somewhere private yields a rule that simply reaches nobody. + debug_ip="$(getent ahostsv4 "${DEBUG_HOSTNAME}" | awk '{ print $1; exit }' || true)" + ipv4_octet='(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])' + if [[ ! "${debug_ip}" =~ ^${ipv4_octet}\.${ipv4_octet}\.${ipv4_octet}\.${ipv4_octet}$ ]]; then + echo "::error::The operator hostname did not resolve to a bare IPv4 address." + exit 1 + fi + echo "::add-mask::${debug_ip}" + echo "DEBUG_IP=${debug_ip}" >> "${GITHUB_ENV}" + echo 'operator IPv4 resolved and validated' + + - name: Terraform init + id: tf-init + working-directory: .frameworks/aws-terraform-framework/terraform + env: + AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} + run: | + set -euo pipefail + terraform init -input=false \ + -backend-config="bucket=${AWS_ACCOUNT_ID}-terraform" \ + -backend-config="key=${{ github.repository }}/aws-poc.tfstate" \ + -backend-config="region=${AWS_REGION}" + + # A killed predecessor can leave state and an instance whose one-time SSH private key no + # longer exists. Never adopt it: destroy under the same lock, then apply with a fresh key. + # A live/out-of-band lock makes this fail closed. + - name: Remove interrupted predecessor state before apply + timeout-minutes: 15 + working-directory: .frameworks/aws-terraform-framework/terraform + env: + GITHUB_REPOSITORY_ID: ${{ github.repository_id }} + run: | + set -euo pipefail + terraform destroy -input=false -auto-approve -lock-timeout=300s \ + -var-file="${GITHUB_WORKSPACE}/terraform/aws.tfvars" \ + -var "commit_sha=$(git -C "${GITHUB_WORKSPACE}" rev-parse HEAD)" \ + -var "environment=test" \ + -var "repository=${GITHUB_REPOSITORY}" \ + -var "repository_id=${GITHUB_REPOSITORY_ID}" \ + -var "run_id=${GITHUB_RUN_ID}" \ + -var "runner_ip=${RUNNER_IP}" \ + ${DEBUG_IP:+-var "debug_ip=${DEBUG_IP}"} + + # The value file comes FIRST and the identity flags after it. Terraform resolves repeated + # command-line assignments in the order they are given, so this ordering -- not the kind of + # flag -- is what stops the file from renaming who this fresh deployment says it is. + - name: Terraform apply + id: tf-apply + timeout-minutes: 15 + working-directory: .frameworks/aws-terraform-framework/terraform + run: | + set -euo pipefail + terraform apply -input=false -auto-approve -lock-timeout=120s \ + -var-file="${GITHUB_WORKSPACE}/terraform/aws.tfvars" \ + -var "commit_sha=$(git -C "${GITHUB_WORKSPACE}" rev-parse HEAD)" \ + -var "environment=test" \ + -var "repository=${GITHUB_REPOSITORY}" \ + -var "repository_id=${GITHUB_REPOSITORY_ID}" \ + -var "run_id=${GITHUB_RUN_ID}" \ + -var "runner_ip=${RUNNER_IP}" \ + ${DEBUG_IP:+-var "debug_ip=${DEBUG_IP}"} + + # This repo's roles overlay onto the pinned framework tree. A framework-TRACKED role + # path is refused so an overlay can never replace framework-owned code. + - name: Compose the Ansible tree + run: | + set -euo pipefail + # A role that runs a first-class PowerShell script tracks only files/.ps1.stub; + # the script itself is developed and pair-tested under scripts/ and copied into place + # here. This must precede the rsync below, which is what carries the materialized copy + # into the composed tree. + bash "${GITHUB_WORKSPACE}/scripts/materialize-role-scripts.sh" + + framework_dir="${GITHUB_WORKSPACE}/.frameworks/ansible-framework" + + # The framework's own roles track only files/.ps1.stub. Materialize them in the + # checkout before the overlay: that materializer validates every stub it finds, and this + # repository's roles resolve their sources from this repository, not from the framework. + if [ -x "${framework_dir}/scripts/materialize-role-scripts.sh" ]; then + (cd "${framework_dir}" && ./scripts/materialize-role-scripts.sh) + fi + + for role_source in "${GITHUB_WORKSPACE}"/ansible/applications/*; do + role_name="$(basename "${role_source}")" + [ -f "${role_source}/tasks/main.yml" ] || { echo "!! missing tasks/main.yml in ${role_source}" >&2; exit 1; } + [ -z "$(git -C "${framework_dir}" ls-files -- "applications/${role_name}/")" ] \ + || { echo "!! refusing framework-tracked role path applications/${role_name}" >&2; exit 1; } + rsync -a --delete "${role_source}/" "${framework_dir}/applications/${role_name}/" + done + + # Readiness, configuration and the application proof all live in the playbook. The EC2 + # inventory plugin filters live AWS instances to this repository and run before exposing + # one target. The budget guards against a hung converge without cutting a working one short. + - name: Run the application playbook + timeout-minutes: 60 + working-directory: .frameworks/ansible-framework + env: + ANSIBLE_CONFIG: ${{ github.workspace }}/.frameworks/ansible-framework/ansible.cfg + # A secret reaches the shell through the environment, never by substitution into the + # script text, so its value cannot be read as shell syntax. Forwarded on as an + # extra-var below because the play requires it; same value the OIDC role ARN uses. + AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} + # NOT extra-vars: the dynamic inventory reads these from the environment to pick this + # run's instance, and a lookup('env') has no command-line equivalent. + GITHUB_REPOSITORY_ID: ${{ github.repository_id }} + GITHUB_RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + # Dynamic inventory publishes an instance attribute named state; extra-vars take precedence. + ansible-playbook "${GITHUB_WORKSPACE}/ansible/playbooks/gitlab-aws.yml" \ + --extra-vars aws_account_id="${AWS_ACCOUNT_ID}" \ + --extra-vars aws_region="${AWS_REGION}" \ + --extra-vars env=test \ + --extra-vars state=present \ + --inventory "${GITHUB_WORKSPACE}/ansible/inventory/aws_ec2.yml" + + - name: Hold the converged guest for interactive work + if: >- + !cancelled() && + github.event_name == 'workflow_dispatch' && + inputs.hold_minutes != '0' && + steps.tf-apply.outcome == 'success' + timeout-minutes: 245 + env: + HOLD_MINUTES: ${{ inputs.hold_minutes }} + run: | + set -euo pipefail + instance_id="$( + aws ec2 describe-instances \ + --filters "Name=tag:RepositoryId,Values=${GITHUB_REPOSITORY_ID}" \ + "Name=tag:RunId,Values=${GITHUB_RUN_ID}" \ + "Name=tag:Repository,Values=${GITHUB_REPOSITORY}" \ + "Name=tag:Environment,Values=test" \ + "Name=instance-state-name,Values=running" \ + --query 'Reservations[].Instances[].InstanceId' \ + --output text 2>/dev/null || true + )" + if [[ ! "${instance_id}" =~ ^i-[0-9a-f]+$ ]]; then + instance_id='unavailable' + fi + + remaining_minutes="${HOLD_MINUTES}" + echo "Holding EC2 instance ${instance_id} for ${remaining_minutes} minutes." + while (( remaining_minutes > 0 )); do + sleep 300 + remaining_minutes=$((remaining_minutes - 5)) + echo "Hold heartbeat for EC2 instance ${instance_id}: ${remaining_minutes} minutes remaining." + done + exit 0 + + - name: Refresh AWS credentials for teardown (OIDC) + if: always() && steps.tf-init.outcome == 'success' + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ env.DEPLOY_ROLE }} + aws-region: ${{ env.AWS_REGION }} + allowed-account-ids: ${{ secrets.AWS_ACCOUNT_ID }} + role-session-name: aws-deploy-${{ github.run_id }}-teardown + role-duration-seconds: 3600 + + # Gated on INIT, not apply: a failed apply still leaves state to tear down. + - name: Terraform destroy (always) + id: tf-destroy + if: >- + always() && + steps.tf-init.outcome == 'success' + timeout-minutes: 15 + working-directory: .frameworks/aws-terraform-framework/terraform + env: + GITHUB_REPOSITORY_ID: ${{ github.repository_id }} + run: | + set -euo pipefail + terraform destroy -input=false -auto-approve -lock-timeout=300s \ + -var-file="${GITHUB_WORKSPACE}/terraform/aws.tfvars" \ + -var "commit_sha=$(git -C "${GITHUB_WORKSPACE}" rev-parse HEAD)" \ + -var "environment=test" \ + -var "repository=${GITHUB_REPOSITORY}" \ + -var "repository_id=${GITHUB_REPOSITORY_ID}" \ + -var "run_id=${GITHUB_RUN_ID}" \ + -var "runner_ip=${RUNNER_IP}" \ + ${DEBUG_IP:+-var "debug_ip=${DEBUG_IP}"} + + - name: Remove the ephemeral private key + if: always() + run: | + set -euo pipefail + if [ -n "${CI_PRIVATE_KEY:-}" ]; then + rm -f -- "${CI_PRIVATE_KEY}" + fi + + report: + name: AWS lifecycle incident + needs: [main-ref, aws-deploy] + if: always() && needs.main-ref.result == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + issues: write + env: + GH_TOKEN: ${{ github.token }} + INCIDENT_LABEL: automation-incident + INCIDENT_MARKER: '' + INCIDENT_TITLE: '[automation] AWS lifecycle proof failed' + LIFECYCLE_RESULT: ${{ needs.aws-deploy.result }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + steps: + - name: Update or resolve the lifecycle incident + run: | + set -euo pipefail + + gh label create "${INCIDENT_LABEL}" --repo "${GITHUB_REPOSITORY}" --force \ + --color B60205 --description 'Owned exclusively by recurring automation incidents' > /dev/null + issue_json="$(gh issue list --repo "${GITHUB_REPOSITORY}" --state all --limit 100 \ + --label "${INCIDENT_LABEL}" --json number,title,body,author,state)" + mapfile -t issue_numbers < <(jq -r --arg title "${INCIDENT_TITLE}" \ + --arg marker "${INCIDENT_MARKER}" ' + [.[] | select( + .title == $title + and .author.login == "app/github-actions" + and ((.body // "") | contains($marker)) + )] | sort_by(.number) | .[].number + ' <<< "${issue_json}") + issue_number="${issue_numbers[0]:-}" + for duplicate in "${issue_numbers[@]:1}"; do + if [ "$(gh issue view "${duplicate}" --repo "${GITHUB_REPOSITORY}" \ + --json state --jq .state)" = OPEN ]; then + gh issue close "${duplicate}" --repo "${GITHUB_REPOSITORY}" --reason duplicate \ + --comment "Deduplicated into automation-owned incident #${issue_number}." + fi + done + + if [ "${LIFECYCLE_RESULT}" = success ]; then + if [ -n "${issue_number}" ] && \ + [ "$(gh issue view "${issue_number}" --repo "${GITHUB_REPOSITORY}" --json state --jq .state)" = OPEN ]; then + gh issue close "${issue_number}" --repo "${GITHUB_REPOSITORY}" \ + --reason completed --comment "Recovered in ${RUN_URL}." + else + echo "Lifecycle is healthy; no open incident to resolve." + fi + exit 0 + fi + + message="Lifecycle result: ${LIFECYCLE_RESULT}. Inspect ${RUN_URL}." + message="${message} Cleanup was attempted when initialization succeeded;" + message="${message} inspect the destroy and post-cleanup steps." + if [ -z "${issue_number}" ]; then + gh issue create --repo "${GITHUB_REPOSITORY}" \ + --label "${INCIDENT_LABEL}" --title "${INCIDENT_TITLE}" \ + --body "$(printf '%s\n\n%s' "${INCIDENT_MARKER}" "${message}")" + else + if [ "$(gh issue view "${issue_number}" --repo "${GITHUB_REPOSITORY}" --json state --jq .state)" = CLOSED ]; then + gh issue reopen "${issue_number}" --repo "${GITHUB_REPOSITORY}" + fi + gh issue comment "${issue_number}" --repo "${GITHUB_REPOSITORY}" --body "${message}" + fi diff --git a/.github/workflows/powershell.yml b/.github/workflows/powershell.yml new file mode 100644 index 0000000..6c843b3 --- /dev/null +++ b/.github/workflows/powershell.yml @@ -0,0 +1,48 @@ +# =========================================================================================== # +# File: '.github/workflows/powershell.yml' +# --- [ Description ] ----------------------------------------------------------------------- # +# +# Thin caller for the organization's standardized PowerShell test suite. Everything of +# substance lives in NWarila/powershell-template (the single org definition of PowerShell): +# its reusable pester-matrix workflow discovers every .ps1 + .pester.ps1 pair +# under scripts/ (the generic script home, shared with Python/bash tooling; only .ps1 files +# participate) and runs one matrix leg per script — house PSScriptAnalyzer settings +# (custom rules included, zero findings), then the self-contained Pester spec. +# +# Runs when a PowerShell file changes in a pull request, and again on the merge to main — +# the main run is what publishes default-branch artifacts and code-scanning analyses. +# Adopting new organizational tests is a pin bump on the `uses:` line — nothing else in +# this repository changes. +# +# =========================================================================================== # +--- +name: 'PowerShell' + +"on": + pull_request: + paths: + - 'scripts/**.ps1' # script pairs (.ps1 + .pester.ps1) + - '.github/workflows/powershell.yml' # pin bumps must re-prove the matrix + push: + branches: + - 'main' + paths: + - 'scripts/**.ps1' + - '.github/workflows/powershell.yml' + +permissions: + contents: 'read' + +jobs: + pester-matrix: + permissions: + contents: 'read' + checks: 'write' # report job: check run with per-test results + pull-requests: 'write' # report job: sticky results comment on the PR + security-events: 'write' # test legs: SARIF upload to code scanning + uses: 'NWarila/powershell-template/.github/workflows/pester-matrix.yaml@758b3313d34269f10dcf1a1b07837c0b887bdc87' # main (#31) + with: + scan-path: 'scripts' + # Must match the `uses:` ref: the ref selects the workflow file, while template-ref + # is what the workflow checks out for the harness and analyzer settings. + template-ref: '758b3313d34269f10dcf1a1b07837c0b887bdc87' # main (#31) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..8cfd7e3 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,109 @@ +# =========================================================================================== # +# Repository-local Ansible and shell quality gate. +# Fleet-uniform port of the golden pdq-deploy-inventory workflow: role names are DISCOVERED +# from ansible/applications/ instead of hard-coded, and every Ansible step gates on the tree +# existing, so the same file is correct in a repo whose application work has not started yet. +# =========================================================================================== # +--- +name: Quality + +"on": + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Detect the ansible tree + id: detect + run: | + set -euo pipefail + if compgen -G 'ansible/applications/*/tasks' > /dev/null; then + echo 'has_ansible=true' >> "${GITHUB_OUTPUT}" + else + echo 'has_ansible=false' >> "${GITHUB_OUTPUT}" + echo 'no ansible applications yet; ansible lint steps are skipped' + fi + + - name: Set up Python + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + with: + python-version: '3.12' + + - name: Install quality tools + run: | + set -euo pipefail + pip install -r requirements-quality.txt + # ansible-lint resolves modules offline, so the collections the roles call must be + # present before the syntax check runs. + ansible-galaxy collection install --force \ + --requirements-file requirements-quality.yml + + - name: Lint YAML + if: steps.detect.outputs.has_ansible == 'true' + run: yamllint -c .yamllint.yml ansible + + - name: Read framework pin + id: pins + run: | + set -euo pipefail + mapfile -t lines < .framework-pin + if [ "${#lines[@]}" -ne 1 ]; then + echo '.framework-pin must contain exactly one line' >&2 + exit 1 + fi + pin="${lines[0]}" + if [[ ! "${pin}" =~ ^[0-9a-f]{40}$ ]]; then + echo '.framework-pin must contain one lowercase 40-character Git SHA' >&2 + exit 1 + fi + printf 'ansible=%s\n' "${pin}" >> "${GITHUB_OUTPUT}" + + - name: Checkout ansible-framework (pinned) + if: steps.detect.outputs.has_ansible == 'true' + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + repository: nwarila-platform/ansible-framework + ref: ${{ steps.pins.outputs.ansible }} + path: .frameworks/ansible-framework + persist-credentials: false + + - name: Compose application roles + if: steps.detect.outputs.has_ansible == 'true' + run: | + set -euo pipefail + scripts/materialize-role-scripts.sh + # The framework's roles carry stubs of their own; materialize them in the checkout + # before the overlay, for the same reason the deploy workflow does. + if [ -x .frameworks/ansible-framework/scripts/materialize-role-scripts.sh ]; then + (cd .frameworks/ansible-framework && ./scripts/materialize-role-scripts.sh) + fi + for role_source in ansible/applications/*/; do + role_name="$(basename "${role_source}")" + rsync -a --delete "${role_source}" \ + ".frameworks/ansible-framework/applications/${role_name}/" + done + + - name: Lint Ansible + if: steps.detect.outputs.has_ansible == 'true' + working-directory: .frameworks/ansible-framework + run: | + set -euo pipefail + roles=() + for role_source in "${GITHUB_WORKSPACE}"/ansible/applications/*/; do + roles+=("applications/$(basename "${role_source}")") + done + ansible-lint "${roles[@]}" + + - name: Check shell syntax + run: bash -n scripts/compose-and-run.sh scripts/materialize-role-scripts.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4bd7b9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# =========================================================================================== # +# File: '.gitignore' +# --- [ Description ] ----------------------------------------------------------------------- # +# +# DEFAULT-DENY, EXPLICIT INCLUDES ONLY. Nothing in this working tree is tracked unless it is +# explicitly allowed below, by name — no wildcard allowances. Adding a new file therefore +# requires allowing it here first — that is deliberate: it makes publishing a file an +# explicit act rather than an accident. +# +# =========================================================================================== # + +# Ignore everything ... +** + +# --- [ directory spine ] -------------------------------------------------------------------- # +# Git does not descend into an excluded directory, so every directory on the path to an +# allowed file is re-included by name. This publishes no files by itself: files inside these +# directories stay ignored until named in a section below. +!/.github/ +!/.github/workflows/ +!/scripts/ + +# --- [ allowed: repository configuration ] -------------------------------------------------- # +!/.editorconfig +!/.framework-pin +!/.gitignore +!/.yamllint.yml + +# --- [ allowed: top-level documents ] ------------------------------------------------------- # +!/README.md +!/quality-tools.env +!/requirements-quality.txt +!/requirements-quality.yml + +# --- [ allowed: GitHub configuration ] ------------------------------------------------------ # +!/.github/CODEOWNERS +!/.github/terraform-framework-pin +!/.github/workflows/aws-deploy.yml +!/.github/workflows/powershell.yml +!/.github/workflows/quality.yml + +# --- [ allowed: scripts ] ------------------------------------------------------------------- # +!/scripts/compose-and-run.sh +!/scripts/materialize-role-scripts.sh diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..cce5578 --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,86 @@ +--- +# ============================================================================= +# yamllint Configuration +# ============================================================================= +# Extends the default ruleset with Ansible-appropriate overrides. +# Ansible YAML files tend to have long module parameter blocks and inline +# Jinja2 expressions — configure accordingly without sacrificing correctness. +# ============================================================================= + +extends: default + +rules: + + # --------------------------------------------------------------------------- + # Line Length + # --------------------------------------------------------------------------- + # 160 chars accommodates verbose module parameter blocks. Warning-only so + # pipelines don't fail on rare exceptions; keep lines short by convention. + # --------------------------------------------------------------------------- + line-length: + max: 160 + level: warning + + # --------------------------------------------------------------------------- + # Truthy Values + # --------------------------------------------------------------------------- + # Enforce canonical YAML 1.2 booleans (true/false). Ansible historically + # accepted yes/no/on/off, but these are ambiguous and deprecated in + # Ansible 2.17+ (YAML 1.1 legacy). Quoted 'yes'/'no' strings are allowed. + # --------------------------------------------------------------------------- + truthy: + allowed-values: ['true', 'false'] + check-keys: true + + # --------------------------------------------------------------------------- + # Document Start + # --------------------------------------------------------------------------- + # Disabled — Ansible task files and role files commonly start with comments + # or directly with list items. Enforce via convention, not linting. + # --------------------------------------------------------------------------- + document-start: disable + + # --------------------------------------------------------------------------- + # Comments + # --------------------------------------------------------------------------- + # Require 1 space between code and inline comment (#). + # Disable indentation enforcement — region comments and block headers + # intentionally sit at column 0 or use custom indentation. + # --------------------------------------------------------------------------- + comments: + min-spaces-from-content: 1 + comments-indentation: disable + + # --------------------------------------------------------------------------- + # Braces & Brackets + # --------------------------------------------------------------------------- + # Allow up to 1 space inside braces and brackets to match common Jinja2 + # and Ansible inline dict/list style: { key: value } and [ item ]. + # --------------------------------------------------------------------------- + braces: + min-spaces-inside: 0 + max-spaces-inside: 1 + brackets: + min-spaces-inside: 0 + max-spaces-inside: 1 + + # --------------------------------------------------------------------------- + # Indentation + # --------------------------------------------------------------------------- + # Standard 2-space indentation. indent-sequences: consistent allows both + # block and flow sequence styles as long as they're internally consistent. + # --------------------------------------------------------------------------- + indentation: + spaces: 2 + indent-sequences: consistent + check-multi-line-strings: false + + # --------------------------------------------------------------------------- + # New Line at End of File + # --------------------------------------------------------------------------- + new-line-at-end-of-file: enable + + # --------------------------------------------------------------------------- + # Trailing Spaces + # --------------------------------------------------------------------------- + trailing-spaces: enable diff --git a/quality-tools.env b/quality-tools.env new file mode 100644 index 0000000..25b748b --- /dev/null +++ b/quality-tools.env @@ -0,0 +1,9 @@ +# shellcheck shell=sh +# shellcheck disable=SC2034 # This file is sourced; its assignments are its public interface. +# Exact non-Python tools installed or asserted by .github/workflows/quality.yml. +ACTIONLINT_VERSION='1.7.7' +ACTIONLINT_LINUX_AMD64_SHA256='023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757' +SHELLCHECK_APT_VERSION='0.9.0-1' +SESSION_MANAGER_PLUGIN_VERSION='1.2.835.0' +SESSION_MANAGER_PLUGIN_DEB_SHA256='7c6dcad12518571cc7959a713e6a8ae1bdf6ed66fd9bee37dc189e39ca58ae03' +TERRAFORM_VERSION='1.15.1' diff --git a/requirements-quality.txt b/requirements-quality.txt new file mode 100644 index 0000000..f982615 --- /dev/null +++ b/requirements-quality.txt @@ -0,0 +1,41 @@ +# Fully resolved on Python 3.12/Linux. Every package is exact so a clean runner cannot silently +# select a newer transitive dependency. Renovate groups this manifest for review; the exact +# quality-tools.env version/checksum tuples remain manual under TD-007. +ansible-compat==26.6.0 +ansible-core==2.21.2 +ansible-lint==26.6.0 +attrs==26.1.0 +black==26.5.1 +boto3==1.43.53 +botocore==1.43.53 +bracex==3.0.1 +cffi==2.1.1 +click==8.4.2 +cryptography==50.0.0 +distro==1.9.0 +filelock==3.32.2 +Jinja2==3.1.6 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +jmespath==1.1.0 +MarkupSafe==3.0.3 +mypy_extensions==1.1.0 +packaging==26.3 +pathspec==1.1.1 +platformdirs==4.11.0 +pycparser==3.0 +python-dateutil==2.9.0.post0 +PyYAML==6.0.3 +pytokens==0.4.1 +referencing==0.37.0 +resolvelib==1.2.1 +rpds-py==2026.6.3 +ruamel.yaml==0.19.1 +ruamel.yaml.clib==0.2.15 +s3transfer==0.19.2 +six==1.17.0 +subprocess-tee==0.4.2 +typing_extensions==4.16.0 +urllib3==2.7.0 +yamllint==1.38.0 +wcmatch==11.0 diff --git a/requirements-quality.yml b/requirements-quality.yml new file mode 100644 index 0000000..1ae7cc1 --- /dev/null +++ b/requirements-quality.yml @@ -0,0 +1,12 @@ +--- +# Collections used while linting the composed application + pinned framework tree. +collections: + - name: amazon.aws + version: '11.4.0' + - name: ansible.windows + version: '3.7.0' + - name: community.windows + version: '3.3.0' + # The framework's domain_member role performs its Windows join through microsoft.ad. + - name: microsoft.ad + version: '1.12.0' diff --git a/scripts/compose-and-run.sh b/scripts/compose-and-run.sh new file mode 100755 index 0000000..29b09e9 --- /dev/null +++ b/scripts/compose-and-run.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +# =========================================================================================== # +# File: 'scripts/compose-and-run.sh' +# --- [ Description ] ----------------------------------------------------------------------- # +# +# Composition runner: builds the combined execution tree and runs the playbook. +# +# 1. Clones/updates nwarila-platform/ansible-framework into .compose/ansible-framework +# and checks out the commit pinned in .framework-pin (tags once upstream releases). +# 2. Overlays this repo's roles into the framework's applications/ namespace (rsync +# --delete so stale files never linger). +# 3. Runs the selected playbook with the framework's ansible.cfg as the chassis +# (its roles_path resolves roles by bare name). +# +# Usage: scripts/compose-and-run.sh [-e env=dev] [any extra ansible-playbook args...] +# COMPOSE_PLAYBOOK=.yml to select a playbook under ansible/playbooks/ (default gitlab-aws.yml). +# COMPOSE_INVENTORY= to select a repository-relative inventory file. +# +# =========================================================================================== # +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE_DIR="${REPO_ROOT}/.compose" +FRAMEWORK_DIR="${COMPOSE_DIR}/ansible-framework" +FRAMEWORK_REMOTE='git@github.com:nwarila-platform/ansible-framework.git' +PIN_FILE="${REPO_ROOT}/.framework-pin" +ANSIBLE_PLAYBOOK="${ANSIBLE_PLAYBOOK:-/root/.local/bin/ansible-playbook}" + +[ -f "${PIN_FILE}" ] || { echo "!! missing ${PIN_FILE}" >&2; exit 1; } +PIN="$(tr -d '[:space:]' < "${PIN_FILE}")" +if ! [[ "${PIN}" =~ ^[0-9a-f]{40}$ ]]; then + echo "!! .framework-pin must be a full 40-character lowercase git commit SHA, not a tag/branch: '${PIN}'" >&2 + exit 1 +fi +COMPOSE_PLAYBOOK_VALUE="${COMPOSE_PLAYBOOK-gitlab-aws.yml}" + +# --- 0a. Playbook selection (fail closed before any side effect) ---------------------------- # +if [ -z "${COMPOSE_PLAYBOOK_VALUE}" ]; then + echo "!! COMPOSE_PLAYBOOK resolved path is empty" >&2 + exit 1 +fi + +if [[ "${COMPOSE_PLAYBOOK_VALUE}" = /* ]]; then + echo "!! COMPOSE_PLAYBOOK resolved path is absolute: ${COMPOSE_PLAYBOOK_VALUE}" >&2 + exit 1 +fi + +root_real="$(realpath -e "${REPO_ROOT}/ansible/playbooks")" +target_candidate="${root_real}/${COMPOSE_PLAYBOOK_VALUE}" +if ! target="$(realpath -e "${target_candidate}" 2>/dev/null)"; then + echo "!! COMPOSE_PLAYBOOK resolved path does not exist: ${target_candidate}" >&2 + exit 1 +fi + +if [ ! -f "${target}" ]; then + echo "!! COMPOSE_PLAYBOOK resolved path is not a file: ${target}" >&2 + exit 1 +fi + +case "${target}" in + "${root_real}"/*) ;; + *) + echo "!! COMPOSE_PLAYBOOK resolved path escapes ${root_real}: ${target}" >&2 + exit 1 + ;; +esac +COMPOSE_PLAYBOOK_PATH="${target}" + +# --- 0b. Inventory selection (optional override, fail closed before any side effect) --------- # +COMPOSE_INVENTORY_PATH="${REPO_ROOT}/ansible/inventory/aws_ec2.yml" +if [[ -v COMPOSE_INVENTORY ]]; then + if [ -z "${COMPOSE_INVENTORY}" ]; then + printf '!! COMPOSE_INVENTORY resolved path is empty: %q\n' "${COMPOSE_INVENTORY}" >&2 + exit 1 + fi + + if [[ "${COMPOSE_INVENTORY}" = /* ]]; then + printf '!! COMPOSE_INVENTORY resolved path is absolute: %q\n' "${COMPOSE_INVENTORY}" >&2 + exit 1 + fi + + inventory_root_real="$(realpath -e "${REPO_ROOT}")" + inventory_candidate="${inventory_root_real}/${COMPOSE_INVENTORY}" + if ! inventory_target="$(realpath -e "${inventory_candidate}" 2>/dev/null)"; then + printf '!! COMPOSE_INVENTORY resolved path does not exist: %q\n' "${COMPOSE_INVENTORY}" >&2 + exit 1 + fi + + if [ ! -f "${inventory_target}" ]; then + printf '!! COMPOSE_INVENTORY resolved path is not a file: %q\n' "${COMPOSE_INVENTORY}" >&2 + exit 1 + fi + + case "${inventory_target}" in + "${inventory_root_real}"/*) ;; + *) + printf '!! COMPOSE_INVENTORY resolved path escapes repository root: %q\n' \ + "${COMPOSE_INVENTORY}" >&2 + exit 1 + ;; + esac + COMPOSE_INVENTORY_PATH="${inventory_target}" +fi + +# --- 0c. SSH mux isolation (stale ControlMaster sockets hang runs indefinitely) ------------ # +# An interrupted or killed run can leave a stale SSH multiplex socket to the target, which +# stalls the next play at its first task. Keep Ansible's control sockets repo-local and start +# every run with a clean dir. (Observed 2026-07-15: a stale ~/.ansible/cp socket stalled a run, +# which completed after the socket was removed.) +export ANSIBLE_SSH_CONTROL_PATH_DIR="${COMPOSE_DIR}/.cp" +rm -rf "${ANSIBLE_SSH_CONTROL_PATH_DIR}" +mkdir -p "${ANSIBLE_SSH_CONTROL_PATH_DIR}" + +# --- 1. Framework checkout at the pin ------------------------------------------------------- # +mkdir -p "${COMPOSE_DIR}" +if [ ! -d "${FRAMEWORK_DIR}/.git" ]; then + echo ">> Cloning ansible-framework ..." + git clone --quiet "${FRAMEWORK_REMOTE}" "${FRAMEWORK_DIR}" +fi +git -C "${FRAMEWORK_DIR}" fetch --quiet origin +git -C "${FRAMEWORK_DIR}" checkout --quiet --detach "${PIN}" +echo ">> Framework pinned at $(git -C "${FRAMEWORK_DIR}" rev-parse --short HEAD)" + +# The framework's own roles track only files/.ps1.stub, so their scripts must be +# materialized in the checkout. This runs BEFORE the overlay because that materializer validates +# every stub it finds, and this repository's roles resolve their sources from this repository. +if [ -x "${FRAMEWORK_DIR}/scripts/materialize-role-scripts.sh" ]; then + (cd "${FRAMEWORK_DIR}" && ./scripts/materialize-role-scripts.sh) +fi + +# --- 2. Overlay roles into the framework namespace ------------------------------------------ # +shopt -s nullglob +role_sources=("${REPO_ROOT}"/ansible/applications/*) +shopt -u nullglob + +if [ "${#role_sources[@]}" -eq 0 ]; then + echo "!! no role sources found under ${REPO_ROOT}/ansible/applications" >&2 + exit 1 +fi + +mapfile -t role_sources < <(printf '%s\n' "${role_sources[@]}" | LC_ALL=C sort) +overlaid_roles=0 +validated_roles=() + +# Pass 1 — VALIDATE EVERY candidate before mutating anything. Deliberately separate from the +# rsync pass: validating and overlaying in one loop lets a valid role be written to the +# framework tree before a later invalid one aborts the run, leaving a partial overlay behind. +# Same rule the composer already applies to COMPOSE_PLAYBOOK (0a) — no side effect precedes +# validation. +for role_source in "${role_sources[@]}"; do + role_name="$(basename "${role_source}")" + + if [[ ! "${role_name}" =~ ^[a-z][a-z0-9_]*$ ]]; then + echo "!! invalid role basename '${role_name}' at ${role_source}" >&2 + exit 1 + fi + + # -L before -d: -d follows symlinks, so a symlink-to-dir would otherwise pass as a role + # and rsync's trailing slash would read through it, outside the intended tree. + if [ -L "${role_source}" ]; then + echo "!! refusing symlinked role source: ${role_source}" >&2 + exit 1 + fi + + if [ ! -d "${role_source}" ]; then + echo "!! role source is not a directory: ${role_source}" >&2 + exit 1 + fi + + if [ ! -f "${role_source}/tasks/main.yml" ]; then + echo "!! role source missing tasks/main.yml: ${role_source}/tasks/main.yml" >&2 + exit 1 + fi + + # The real guard on rsync --delete: prove the destination is not a framework-OWNED role. + # Reads the index, so a tracked-but-absent working-tree file still trips it. + tracked_role_files="$(git -C "${FRAMEWORK_DIR}" ls-files -- "applications/${role_name}/")" + if [ -n "${tracked_role_files}" ]; then + echo "!! refusing to overlay framework-tracked role path: ${FRAMEWORK_DIR}/applications/${role_name}" >&2 + echo "${tracked_role_files}" >&2 + exit 1 + fi + + validated_roles+=("${role_name}") +done + +# Pass 2 — overlay only after every candidate has passed. +for role_name in "${validated_roles[@]}"; do + rsync -a --delete \ + "${REPO_ROOT}/ansible/applications/${role_name}/" \ + "${FRAMEWORK_DIR}/applications/${role_name}/" + echo ">> Overlaid role '${role_name}' into framework applications/" + overlaid_roles=$((overlaid_roles + 1)) +done + +if [ "${overlaid_roles}" -eq 0 ]; then + echo "!! no roles overlaid into framework applications/" >&2 + exit 1 +fi + +# --- 3. Execute with the framework chassis --------------------------------------------------- # +cd "${FRAMEWORK_DIR}" +export ANSIBLE_CONFIG="${FRAMEWORK_DIR}/ansible.cfg" +exec "${ANSIBLE_PLAYBOOK}" \ + -i "${COMPOSE_INVENTORY_PATH}" \ + "${COMPOSE_PLAYBOOK_PATH}" \ + "$@" diff --git a/scripts/materialize-role-scripts.sh b/scripts/materialize-role-scripts.sh new file mode 100755 index 0000000..209b5e7 --- /dev/null +++ b/scripts/materialize-role-scripts.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Resolves every PowerShell stub marker in the ansible tree (org three-file +# convention: powershell-template docs/reference/pester-pair-testing.md §1). +# +# A role that executes a first-class PowerShell script carries +# files/.ps1.stub instead of the script itself; the script is developed, +# reviewed, and pair-tested once under scripts/. This build step copies each +# stub's source script to files/.ps1 so the role runs effortlessly. The +# materialized copies are build artifacts: the default-deny .gitignore never +# allowlists them, so they cannot be committed. +# +# Fail-closed contract, checked before any copy: +# * stub content (comments/blank lines stripped) is exactly one repo-relative +# source path; +# * the source exists, and its basename + '.stub' equals the stub's basename, +# so a copy-pasted stub pointing at the wrong script dies loudly; +# * the source has a sibling .pester.ps1 spec — an untested script +# never reaches a role. +# +# Usage: scripts/materialize-role-scripts.sh [--check] +# --check verify every stub resolves and every materialized copy is current, +# without writing anything (CI/lint mode). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CHECK_ONLY=0 +[ "${1:-}" = '--check' ] && CHECK_ONLY=1 + +FAILED=0 +fail() { + printf 'materialize-role-scripts: %s\n' "$1" >&2 + FAILED=1 +} + +mapfile -t stubs < <(find "${ROOT}/ansible/applications" -type f -name '*.ps1.stub' | sort) + +if [ "${#stubs[@]}" -eq 0 ]; then + printf 'materialize-role-scripts: no stubs found; nothing to do\n' + exit 0 +fi + +for stub in "${stubs[@]}"; do + rel_stub="${stub#"${ROOT}"/}" + + mapfile -t lines < <(grep -vE '^[[:space:]]*(#|$)' "${stub}" || true) + if [ "${#lines[@]}" -ne 1 ]; then + fail "${rel_stub}: expected exactly one source path line, found ${#lines[@]}" + continue + fi + + source_rel="$(printf '%s' "${lines[0]}" | tr -d '[:space:]')" + source_abs="${ROOT}/${source_rel}" + expected_stub_name="$(basename "${source_rel}").stub" + + if [ "$(basename "${stub}")" != "${expected_stub_name}" ]; then + fail "${rel_stub}: names '${source_rel}' but a stub for it must be called '${expected_stub_name}'" + continue + fi + if [ ! -f "${source_abs}" ]; then + fail "${rel_stub}: source '${source_rel}' does not exist" + continue + fi + if [ ! -f "${source_abs%.ps1}.pester.ps1" ]; then + fail "${rel_stub}: source '${source_rel}' has no sibling .pester.ps1 spec; an untested script never reaches a role" + continue + fi + + target="${stub%.stub}" + rel_target="${target#"${ROOT}"/}" + if [ "${CHECK_ONLY}" -eq 1 ]; then + # The materialized copy is a BUILD-TREE artifact: its absence here is the normal, + # clean state of a source checkout. Only an existing-but-stale copy is an error, + # because a stale copy is what actually executes. + if [ ! -f "${target}" ]; then + printf 'materialize-role-scripts: OK %s (stub resolves; not materialized — clean source tree)\n' "${rel_stub}" + elif ! cmp -s "${source_abs}" "${target}"; then + fail "${rel_target}: stale; differs from '${source_rel}' — re-run scripts/materialize-role-scripts.sh or delete the copy" + else + printf 'materialize-role-scripts: OK %s (materialized and current)\n' "${rel_target}" + fi + else + cp "${source_abs}" "${target}" + printf 'materialize-role-scripts: %s <- %s\n' "${rel_target}" "${source_rel}" + fi +done + +exit "${FAILED}" From 9265a707b0d45abee48e232c78d58120245f689d Mon Sep 17 00:00:00 2001 From: NWarila <33955773+NWarila@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:06:26 +0000 Subject: [PATCH 2/5] feat: add the golden dynamic EC2 inventory One-line parameterization of the Golden Repo inventory: the gitlab_servers group selects Function=gitlab hosts; everything else is carried unchanged. --- .gitignore | 6 ++ ansible/inventory/README.md | 47 +++++++++ ansible/inventory/aws_ec2.yml | 176 ++++++++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 ansible/inventory/README.md create mode 100644 ansible/inventory/aws_ec2.yml diff --git a/.gitignore b/.gitignore index 4bd7b9f..a684c91 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,8 @@ # directories stay ignored until named in a section below. !/.github/ !/.github/workflows/ +!/ansible/ +!/ansible/inventory/ !/scripts/ # --- [ allowed: repository configuration ] -------------------------------------------------- # @@ -39,6 +41,10 @@ !/.github/workflows/powershell.yml !/.github/workflows/quality.yml +# --- [ allowed: the ansible tree ] ---------------------------------------------------------- # +!/ansible/inventory/README.md +!/ansible/inventory/aws_ec2.yml + # --- [ allowed: scripts ] ------------------------------------------------------------------- # !/scripts/compose-and-run.sh !/scripts/materialize-role-scripts.sh diff --git a/ansible/inventory/README.md b/ansible/inventory/README.md new file mode 100644 index 0000000..85995a1 --- /dev/null +++ b/ansible/inventory/README.md @@ -0,0 +1,47 @@ +# ansible/inventory/ + +## There is no static inventory, and that is deliberate + +The AWS deploy is **ephemeral**: every run creates a new instance, converges it, and destroys it. +An instance id written into a file here would be wrong the moment the run that produced it ended. + +## `aws_ec2.yml` — one run's instance, describing itself + +The file is in two parts. The first is the only part that is about this repository: the region, the +four tag filters that select one run's instance — `RepositoryId`, `RunId` and `Repository` from the +workflow's own environment, and `Environment` from `ENVIRONMENT` or `test` — and the `gitlab_servers` +group the play addresses. Everything below that is carried unchanged by any repository deploying a +host this way. + +Hosts are named by their **Name tag**, which is the hostname Terraform declares, so +`inventory_hostname` is the system's own name and nothing downstream has to be told it again. Every +attribute the plugin publishes is namespaced with `aws_`, which keeps the EC2 instance `state` from +colliding with the role input that selects `present_windows.yml` or `absent_windows.yml`. + +## Everything else is derived from the instance + +| Value | Derived from | +|---|---| +| Operating system, login account, shell type | `platform_details`, which every instance carries and which names the platform it is licensed as | +| Connection, port, address, SSM proxy | the `Connection` tag | +| `ENV` (the framework loader's input) | the `Environment` tag | +| Private key | `CI_PRIVATE_KEY` when the workflow staged one, else the account key pair | + +The `Connection` tag takes four values, and absent means `ssh-direct`: + +| Value | Reaches the host by | +|---|---| +| `ssh-direct` | SSH to the routable address on 22 | +| `ssh-ssm` | SSH to the instance id, tunnelled by an SSM `ProxyCommand`; needs no inbound rule | +| `winrm-direct` | WinRM over HTTPS to the routable address on 5986 | +| `winrm-ssm` | WinRM over HTTPS to a local port an SSM port-forwarding session already holds open | + +A WinRM leg also needs a password, because WinRM has no key authentication; the SSH legs +authenticate with the key pair. + +## Running the playbook by hand + +Export `GITHUB_REPOSITORY_ID`, `GITHUB_RUN_ID` and `GITHUB_REPOSITORY` plus AWS credentials, then +point `-i` at `aws_ec2.yml` while the instance still exists. Set `ENVIRONMENT` if the deployment is +not the default `test`. The play asserts its ownership contract, so a run whose tags do not match +fails closed. diff --git a/ansible/inventory/aws_ec2.yml b/ansible/inventory/aws_ec2.yml new file mode 100644 index 0000000..a32339a --- /dev/null +++ b/ansible/inventory/aws_ec2.yml @@ -0,0 +1,176 @@ +# =========================================================================================== # +# File: 'ansible/inventory/aws_ec2.yml' +# --- [ Description ] ----------------------------------------------------------------------- # +# +# The dynamic EC2 inventory, and the only one: the deployment is ephemeral, so an instance id +# written into a static file here would be wrong the moment its run ended. This finds the instance +# the current run created and derives every connection setting from that instance, which is why the +# playbook declares no connection variables of its own. +# +# =========================================================================================== # +--- +#region ------ [ This Repository ] ------------------------------------------------------------ # +# The play addresses a group, not a run. Terraform stamps each system with a Function tag, so the +# group is the set of functions whose hosts this playbook configures -- add a function to the list +# to have its hosts join, and give a second group its own list to split a deployment by role. +groups: + gitlab_servers: "(aws_tags.Function | default('', true)) in ['gitlab']" + +#endregion --- [ This Repository ] ------------------------------------------------------------ # + + +#region ------ [ Selecting This Run ] --------------------------------------------------------- # +# Where to look, then which instance. These deployments run in one region. +regions: [ us-east-1 ] + +# One run's instance and no other. Three tags are the identifiers GitHub sets for any workflow, so +# a run can only ever see what it created; the fourth names the deployment and defaults to the one +# these pipelines build. Nothing here is particular to this repository. +filters: + instance-state-name: running + 'tag:RepositoryId': "{{ lookup('ansible.builtin.env', 'GITHUB_REPOSITORY_ID') }}" + 'tag:RunId': "{{ lookup('ansible.builtin.env', 'GITHUB_RUN_ID') }}" + 'tag:Repository': "{{ lookup('ansible.builtin.env', 'GITHUB_REPOSITORY') }}" + 'tag:Environment': "{{ lookup('ansible.builtin.env', 'ENVIRONMENT') | default('test', true) }}" + +#endregion --- [ Selecting This Run ] --------------------------------------------------------- # + + +#region ------ [ Plugin Behaviour ] ----------------------------------------------------------- # + +# The inventory plugin itself, and the three settings that decide how it presents what it finds. +plugin: amazon.aws.aws_ec2 + +# Terraform's hostname becomes the Name tag, so naming hosts after it makes inventory_hostname the +# system's own name and nothing downstream needs telling it twice. +hostnames: [ 'tag:Name', instance-id ] + +# Fail on a bad expression rather than silently dropping the variable it was meant to set. +strict: true + +# The plugin publishes a host variable named 'state' -- the EC2 instance state -- which would +# collide with the role input that selects present_windows.yml or absent_windows.yml. Prefixing +# removes the collision at the source. It applies BEFORE compose, so the expressions below read +# prefixed names; hostnames and filters read raw ones. +hostvars_prefix: 'aws_' + +#endregion --- [ Plugin Behaviour ] ----------------------------------------------------------- # + + +#region ------ [ Host Variables ] ------------------------------------------------------------- # +# EVERY EXPRESSION RE-READS THE INSTANCE, never another compose value: Ansible templates them all +# against one snapshot, so an entry cannot see one set beside it. The repetition is load-bearing. +# +# The operating system comes from AWS's own platform_details, which every instance carries and which +# names the platform it is licensed as -- 'Windows', 'Windows with SQL Server', 'Red Hat Enterprise +# Linux'. It is matched, not compared, so every Windows variant answers alike. +compose: + + #region ------ [ Reaching the host ] -------------------------------------------------------- # + # The Connection tag in terraform/aws.tfvars decides all four values below. Absent means + # ssh-direct, which is what this repository's pipeline uses. + # + # ssh-direct SSH to the routable address on 22. + # ssh-ssm SSH to the INSTANCE ID, tunnelled by the ProxyCommand below. No inbound rule. + # winrm-direct WinRM over HTTPS to the routable address on 5986. + # winrm-ssm WinRM over HTTPS to a local port an SSM session already forwards. WinRM has + # no proxy hook, so that session is started alongside rather than from here. + + # Public address first for a controller outside the VPC, private for one inside it. The trailing + # instance id is deliberately NOT dialable: it surfaces an address-less topology as a connection + # error rather than a missing variable. An 'or' chain because Jinja evaluates filter ARGUMENTS + # eagerly, so a nested default() still raises on an absent address. + ansible_host: >- + ((aws_tags.Connection | default('ssh-direct', true)) == 'ssh-ssm') + | ternary( + aws_instance_id, + ((aws_tags.Connection | default('ssh-direct', true)) == 'winrm-ssm') + | ternary( + '127.0.0.1', + (aws_public_ip_address | default('', true)) + or (aws_private_ip_address | default('', true)) + or aws_instance_id + ) + ) + + ansible_connection: >- + ((aws_tags.Connection | default('ssh-direct', true)) in ['winrm-direct', 'winrm-ssm']) + | ternary('winrm', 'ssh') + + ansible_port: >- + ((aws_tags.Connection | default('ssh-direct', true)) in ['winrm-direct', 'winrm-ssm']) + | ternary(5986, 22) + + # Keepalive: a settle poll can wait minutes with no SSH traffic, and an idle connection that times + # out is reported as UNREACHABLE. The 30s probes keep the link from counting as idle; twenty + # unanswered ones are the dead-peer budget. Only the tunnelled leg adds the proxy. + # + # Each quoted fragment stays on ONE line. YAML preserves a line break inside an indented block, + # and a newline landing inside the quoted ProxyCommand would split it into two shell commands -- + # the second failing, the first silently losing its port. Breaks between fragments are harmless. + ansible_ssh_common_args: >- + '-o StrictHostKeyChecking=accept-new -o ServerAliveInterval=30 -o ServerAliveCountMax=20' + ~ ( + ((aws_tags.Connection | default('ssh-direct', true)) == 'ssh-ssm') | + ternary( + ' -o ProxyCommand="aws ssm start-session --target %h' + ~ ' --document-name AWS-StartSSHSession --parameters portNumber=%p"', + '' + ) + ) + + #endregion --- [ Reaching the host ] -------------------------------------------------------- # + + #region ------ [ Logging in ] --------------------------------------------------------------- # + ansible_user: >- + (aws_platform_details | lower is search('windows')) + | ternary('Administrator', 'ec2-user') + + # Over SSH on Windows the bootstrap flips OpenSSH's DefaultShell to PowerShell before any non-raw + # work begins. The value is correct before that flip too, where only raw tasks run and the + # registry reads the bootstrap uses parse under either shell. A WinRM leg is different rather than + # indifferent: that plugin expects cmd and warns when handed powershell. + ansible_shell_type: >- + ((aws_tags.Connection | default('ssh-direct', true)) in ['winrm-direct', 'winrm-ssm']) + | ternary( + 'cmd', + (aws_platform_details | lower is search('windows')) + | ternary('powershell', 'sh') + ) + + # The workflow stages its key and names it; + ansible_ssh_private_key_file: >- + lookup('ansible.builtin.env', 'CI_PRIVATE_KEY') + | default('~/.ssh/nwarila-ec2-key', true) + + #endregion --- [ Logging in ] --------------------------------------------------------------- # + + #region ------ [ WinRM only ] --------------------------------------------------------------- # + # The SSH legs are what this repository deploys over and what its proofs cover. A WinRM leg needs + # three things this repository does not supply: pywinrm on the controller, a password (WinRM has + # no key authentication), and a listener on the target. These are what it would connect with. + + # NTLM rather than basic: the credential never crosses the wire in a header, and it works against + # the workgroup-joined local Administrator a freshly launched instance is. + ansible_winrm_transport: "'ntlm'" + + # An EC2 Windows image generates its 5986 certificate on the instance, so there is nothing to + # chain to. A real downgrade against the SSH legs, which authenticate the host key. + ansible_winrm_server_cert_validation: "'ignore'" + + #endregion --- [ WinRM only ] --------------------------------------------------------------- # + + #region ------ [ Framework inputs ] --------------------------------------------------------- # + # The bootstrap dispatcher reads this to pick a bootstrap role WITHOUT running a fact module + # first, which on Windows would precede the transport repair the bootstrap exists to perform. + platform: >- + (aws_platform_details | lower is search('windows')) + | ternary('Windows', 'Linux') + + # The framework loader requires a variable NAMED 'ENV' and lower-cases the value itself. Read bare + # because the Environment filter above admits only instances that carry the tag. + ENV: "aws_tags.Environment" + + #endregion --- [ Framework inputs ] --------------------------------------------------------- # + +#endregion --- [ Host Variables ] ------------------------------------------------------------- # From f0a3595ebd873829cac3e9ab42c2567b7c3f03f4 Mon Sep 17 00:00:00 2001 From: NWarila <33955773+NWarila@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:20:04 +0000 Subject: [PATCH 3/5] feat(inventory): carry the generalized golden compose variables Mirrors pdq-deploy-inventory#2: ansible_python_interpreter (RHEL -> platform-python, else python3) and windows_password_source (winrm-direct legs only) now live in the shared inventory body. --- ansible/inventory/aws_ec2.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ansible/inventory/aws_ec2.yml b/ansible/inventory/aws_ec2.yml index a32339a..c92bc7c 100644 --- a/ansible/inventory/aws_ec2.yml +++ b/ansible/inventory/aws_ec2.yml @@ -143,6 +143,14 @@ compose: lookup('ansible.builtin.env', 'CI_PRIVATE_KEY') | default('~/.ssh/nwarila-ec2-key', true) + # Linux interpreter: RHEL converges on platform-python, the interpreter its system packages + # target; every other distribution uses the standard python3. Windows connections ignore + # this variable. A task needing a newer boto3 than the pinned interpreter carries overrides + # itself, per-task, onto its bootstrap venv. + ansible_python_interpreter: >- + (aws_platform_details | lower is search('red hat')) + | ternary('/usr/libexec/platform-python', '/usr/bin/python3') + #endregion --- [ Logging in ] --------------------------------------------------------------- # #region ------ [ WinRM only ] --------------------------------------------------------------- # @@ -158,6 +166,12 @@ compose: # chain to. A real downgrade against the SSH legs, which authenticate the host key. ansible_winrm_server_cert_validation: "'ignore'" + # Where the launch password comes from on a WinRM leg. The play that fetches it gates on + # 'ec2'; every other leg composes empty, so SSH-only repositories never trip that play. + windows_password_source: >- + ((aws_tags.Connection | default('ssh-direct', true)) == 'winrm-direct') + | ternary('ec2', '') + #endregion --- [ WinRM only ] --------------------------------------------------------------- # #region ------ [ Framework inputs ] --------------------------------------------------------- # From ef287347fb9752b6ffae0830f7a3e83742460657 Mon Sep 17 00:00:00 2001 From: NWarila <33955773+NWarila@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:14:19 +0000 Subject: [PATCH 4/5] docs: add repository agent guidance AGENTS.md states the org-wide contribution rules (no AI attribution, golden-repo deference, protected main, default-deny gitignore); CLAUDE.md imports it. --- .gitignore | 2 ++ AGENTS.md | 17 +++++++++++++++++ CLAUDE.md | 5 +++++ 3 files changed, 24 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index a684c91..c73da09 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,8 @@ !/.yamllint.yml # --- [ allowed: top-level documents ] ------------------------------------------------------- # +!/AGENTS.md +!/CLAUDE.md !/README.md !/quality-tools.env !/requirements-quality.txt diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bfd90f1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# AGENTS.md + +Guidance for AI agents working in this repository. + +- **No AI attribution, org-wide**: commit messages carry no co-authorship trailers naming + an AI model, assistant, or vendor, and PR bodies, issues, and comments carry no + tool-credit footers or badges. windows-fileserver-ha enforces this with commit hooks + that reject even a mention of such names or attribution phrases in messages and staged + content; the rule applies to every nwarila-platform repository regardless. +- This repository is part of the nwarila-platform fleet normalization: + **pdq-deploy-inventory is the Golden Repo** for structure, coding style, workflows, and + least privilege. Defer to its patterns before any check-in unless this repository has a + stated reason to differ. +- `main` is protected (PRs only, squash merges, signed commits, code-owner review): work on + feature branches, never push to `main`. +- The `.gitignore` is DEFAULT-DENY: a new file must be allowlisted there, by exact name, + before it can be committed. That is deliberate — publishing a file is an explicit act. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..91d991f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +Follow the repository guidance in AGENTS.md: + +@AGENTS.md From b4a904820cb8705a2b4a6ad6e1b2b7d149b70fe7 Mon Sep 17 00:00:00 2001 From: NWarila <33955773+NWarila@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:19:50 +0000 Subject: [PATCH 5/5] docs: add the fleet LICENSE and security policy MIT license carried from the golden repo; SECURITY.md carried from windows-wsus with platform-neutral wording (the one fleet copy that existed), covering supported version and private vulnerability reporting. --- .gitignore | 2 ++ LICENSE | 21 +++++++++++++++++++++ SECURITY.md | 22 ++++++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 LICENSE create mode 100644 SECURITY.md diff --git a/.gitignore b/.gitignore index c73da09..a52cece 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,9 @@ # --- [ allowed: top-level documents ] ------------------------------------------------------- # !/AGENTS.md !/CLAUDE.md +!/LICENSE !/README.md +!/SECURITY.md !/quality-tools.env !/requirements-quality.txt !/requirements-quality.yml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d431807 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Smarter > Harder + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d565fda --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,22 @@ +# Security policy + +## Supported version + +The protected `main` branch is the only supported version. Deployments and +reports should identify the exact commit and the two framework pin commits. + +## Reporting a vulnerability + +Please use GitHub's **Report a vulnerability** flow for this repository. Do +not open a public issue for suspected credential exposure, privilege +escalation, trust-policy bypass, or unsafe resource cleanup. + +Include the affected commit, relevant workflow or policy path, reproduction +steps, and the potential AWS or guest impact. Maintainers aim to acknowledge +a report within three business days and provide an initial assessment within +seven business days. + +If a credential may have been exposed, revoke or rotate it before sharing +diagnostic output. Never include AWS account identifiers, private keys, +certificate passwords, session tokens, or rendered IAM artifacts in a public +report.