|
| 1 | +name: Ubuntu 20 LXD VM Testing Matrix |
| 2 | + |
| 3 | +on: |
| 4 | + pull_request: |
| 5 | + push: |
| 6 | + branches: |
| 7 | + - main |
| 8 | + |
| 9 | +jobs: |
| 10 | + validate: |
| 11 | + name: Validate my profile |
| 12 | + runs-on: ubuntu-latest |
| 13 | + env: |
| 14 | + CHEF_LICENSE: accept-silent |
| 15 | + CHEF_LICENSE_KEY: ${{ secrets.SAF_CHEF_LICENSE_KEY }} |
| 16 | + KITCHEN_LOCAL_YAML: kitchen.lxd.yml |
| 17 | + SAF_PIPELINE_SUBNET: ${{ secrets.SAF_PIPELINE_SUBNET }} |
| 18 | + SAF_PIPELINE_SG: ${{ secrets.SAF_PIPELINE_SG }} |
| 19 | + PLATFORM: "ubuntu-20" |
| 20 | + LC_ALL: "en_US.UTF-8" |
| 21 | + LXD_IMAGE: "ubuntu:20.04" |
| 22 | + LXD_VM_USERNAME: "ubuntu" |
| 23 | + strategy: |
| 24 | + matrix: |
| 25 | + suite: ["vanilla", "hardened"] |
| 26 | + fail-fast: false |
| 27 | + steps: |
| 28 | + - name: add needed packages |
| 29 | + run: | |
| 30 | + sudo apt-get update |
| 31 | + sudo apt-get -y install jq |
| 32 | +
|
| 33 | + - name: Install LXD |
| 34 | + run: | |
| 35 | + sudo snap install lxd |
| 36 | + # Wait for LXD daemon to start |
| 37 | + echo "Waiting for LXD daemon to start..." |
| 38 | + sleep 15 |
| 39 | + # Initialize LXD with default settings |
| 40 | + sudo lxd init --auto |
| 41 | + # Add runner to lxd group |
| 42 | + sudo usermod -a -G lxd runner |
| 43 | + # Fix socket permissions for CI environment |
| 44 | + sudo chmod 666 /var/snap/lxd/common/lxd/unix.socket |
| 45 | +
|
| 46 | + echo "Configuring LXD networking (lxdbr0 with IPv4 NAT, IPv6 disabled)" |
| 47 | + # Ensure managed bridge exists and is configured for IPv4 NAT and DNS |
| 48 | + sudo lxc network show lxdbr0 || true |
| 49 | + sudo lxc network set lxdbr0 ipv4.address auto |
| 50 | + sudo lxc network set lxdbr0 ipv4.nat true |
| 51 | + sudo lxc network set lxdbr0 ipv6.address none || true |
| 52 | + sudo lxc network set lxdbr0 ipv6.nat false || true |
| 53 | + sudo lxc network set lxdbr0 dns.mode managed || true |
| 54 | + sudo lxc network set lxdbr0 dns.domain lxd || true |
| 55 | + sudo lxc network set lxdbr0 dns.nameservers 1.1.1.1,8.8.8.8 || true |
| 56 | + # Make sure default profile attaches NIC to lxdbr0 |
| 57 | + sudo lxc profile device add default eth0 nic name=eth0 network=lxdbr0 || true |
| 58 | +
|
| 59 | + # Helpful sysctl in some CI hosts (usually already enabled by LXD rules) |
| 60 | + sudo sysctl -w net.ipv4.ip_forward=1 || true |
| 61 | + sudo sysctl -w net.ipv4.conf.all.forwarding=1 net.ipv4.conf.default.forwarding=1 || true |
| 62 | +
|
| 63 | + # Ensure NAT and forwarding rules for lxdbr0 on CI hosts with restrictive FORWARD policies |
| 64 | + EXTIF=$(ip -4 route show default | awk '{print $5}' | head -n1) |
| 65 | + SUBNET=$(ip -4 addr show dev lxdbr0 | awk '/inet /{print $2}') |
| 66 | + echo "Ensuring iptables NAT and FORWARD rules for $SUBNET -> $EXTIF" |
| 67 | + sudo iptables -t nat -C POSTROUTING -s "$SUBNET" -o "$EXTIF" -j MASQUERADE 2>/dev/null || sudo iptables -t nat -A POSTROUTING -s "$SUBNET" -o "$EXTIF" -j MASQUERADE |
| 68 | + sudo iptables -C FORWARD -i lxdbr0 -o "$EXTIF" -j ACCEPT 2>/dev/null || sudo iptables -I FORWARD 1 -i lxdbr0 -o "$EXTIF" -j ACCEPT |
| 69 | + sudo iptables -C FORWARD -i "$EXTIF" -o lxdbr0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || sudo iptables -I FORWARD 1 -i "$EXTIF" -o lxdbr0 -m state --state RELATED,ESTABLISHED -j ACCEPT |
| 70 | +
|
| 71 | + # Some hosts default to DROP on FORWARD; set ACCEPT and enable bridge nf hooks |
| 72 | + sudo modprobe br_netfilter || true |
| 73 | + sudo sysctl -w net.bridge.bridge-nf-call-iptables=1 net.bridge.bridge-nf-call-ip6tables=1 || true |
| 74 | + sudo iptables -P FORWARD ACCEPT || true |
| 75 | +
|
| 76 | + # Relax reverse path filtering that can drop forwarded replies in some clouds |
| 77 | + sudo sysctl -w net.ipv4.conf.all.rp_filter=0 net.ipv4.conf.default.rp_filter=0 || true |
| 78 | + sudo sysctl -w net.ipv4.conf.lxdbr0.rp_filter=0 || true |
| 79 | + sudo sysctl -w net.ipv4.conf.$EXTIF.rp_filter=0 || true |
| 80 | +
|
| 81 | + # Test basic LXD functionality |
| 82 | + sudo lxc list |
| 83 | +
|
| 84 | + - name: Check out repository |
| 85 | + uses: actions/checkout@v4 |
| 86 | + |
| 87 | + - name: Clone full repository so we can push |
| 88 | + run: git fetch --prune --unshallow |
| 89 | + |
| 90 | + - name: Set short git commit SHA |
| 91 | + id: vars |
| 92 | + run: | |
| 93 | + calculatedSha=$(git rev-parse --short ${{ github.sha }}) |
| 94 | + echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV |
| 95 | +
|
| 96 | + - name: Confirm git commit SHA output |
| 97 | + run: echo ${{ env.COMMIT_SHORT_SHA }} |
| 98 | + |
| 99 | + - name: Setup Ruby |
| 100 | + uses: ruby/setup-ruby@v1 |
| 101 | + with: |
| 102 | + ruby-version: "3.1" |
| 103 | + |
| 104 | + - name: Disable ri and rdoc |
| 105 | + run: 'echo "gem: --no-ri --no-rdoc" >> ~/.gemrc' |
| 106 | + |
| 107 | + - name: Run Bundle Install |
| 108 | + run: bundle install |
| 109 | + |
| 110 | + - name: Installed Cinc-auditor |
| 111 | + run: bundle exec cinc-auditor version |
| 112 | + |
| 113 | + - name: Vendor the Profile |
| 114 | + run: bundle exec cinc-auditor vendor . --overwrite |
| 115 | + |
| 116 | + - name: Generate ephemeral SSH key for Kitchen |
| 117 | + run: | |
| 118 | + set -euo pipefail |
| 119 | + KEY="$RUNNER_TEMP/kitchen_ed25519" |
| 120 | + ssh-keygen -t ed25519 -a 64 -N "" -f "$KEY" -C "kitchen-ci-$(date -u +%Y%m%dT%H%M%SZ)" </dev/null |
| 121 | + chmod 600 "$KEY" |
| 122 | + chmod 644 "$KEY.pub" |
| 123 | + { |
| 124 | + echo "KITCHEN_SSH_KEY=$KEY" |
| 125 | + echo "KITCHEN_SSH_PUBKEY=$(cat "$KEY.pub")" |
| 126 | + } >> "$GITHUB_ENV" |
| 127 | + |
| 128 | + - name: Launch LXD VM with cloud-init enabling SSH |
| 129 | + run: | |
| 130 | + set -euo pipefail |
| 131 | + NAME="${{ matrix.suite }}-${{ env.PLATFORM }}" |
| 132 | +
|
| 133 | + cat > user-data.yaml <<EOF |
| 134 | + #cloud-config |
| 135 | + write_files: |
| 136 | + - path: /etc/apt/apt.conf.d/99force-ipv4 |
| 137 | + permissions: '0644' |
| 138 | + content: | |
| 139 | + Acquire::ForceIPv4 "true"; |
| 140 | + package_update: true |
| 141 | + packages: |
| 142 | + - openssh-server |
| 143 | + users: |
| 144 | + - name: ${LXD_VM_USERNAME} |
| 145 | + groups: [sudo] |
| 146 | + sudo: ALL=(ALL) NOPASSWD:ALL |
| 147 | + shell: /bin/bash |
| 148 | + ssh_authorized_keys: |
| 149 | + - ${KITCHEN_SSH_PUBKEY} |
| 150 | + ssh_pwauth: false |
| 151 | + EOF |
| 152 | +
|
| 153 | + sudo lxc launch ${LXD_IMAGE} "$NAME" --vm -c user.user-data="$(cat user-data.yaml)" |
| 154 | +
|
| 155 | + - name: Wait for VM to be ready |
| 156 | + run: | |
| 157 | + echo "Waiting for VM to be fully initialized..." |
| 158 | +
|
| 159 | + # Debug: Check VM status |
| 160 | + echo "=== VM Status ===" |
| 161 | + NAME="${{ matrix.suite }}-${{ env.PLATFORM }}" |
| 162 | + sudo lxc list |
| 163 | + sudo lxc info $NAME |
| 164 | +
|
| 165 | + # We check for basic readiness instead of waiting for cloud-init completion |
| 166 | + timeout=300 |
| 167 | + elapsed=0 |
| 168 | + while [ $elapsed -lt $timeout ]; do |
| 169 | + # Debug: Show what we're checking |
| 170 | + echo "=== Checking VM readiness (attempt $((elapsed/10 + 1))) ===" |
| 171 | + |
| 172 | + # Check if VM is running and we can execute commands |
| 173 | + if sudo lxc list --format=csv | grep -q "$NAME.*RUNNING"; then |
| 174 | + echo "VM is in RUNNING state" |
| 175 | + |
| 176 | + # Try to execute a simple command to verify VM is responsive |
| 177 | + if sudo lxc exec $NAME -- echo "VM is responsive" 2>/dev/null; then |
| 178 | + echo "VM is responsive to commands" |
| 179 | + |
| 180 | + # Check if basic system is ready (systemd, if available) |
| 181 | + if sudo lxc exec $NAME -- systemctl is-system-running --wait 2>/dev/null || true; then |
| 182 | + echo "VM system is ready!" |
| 183 | + break |
| 184 | + else |
| 185 | + echo "VM system not fully ready yet, but responsive" |
| 186 | + # For basic VMs, being responsive might be enough |
| 187 | + if [ $elapsed -ge 60 ]; then # After 60 seconds, if responsive, consider ready |
| 188 | + echo "VM has been responsive for sufficient time, considering ready" |
| 189 | + break |
| 190 | + fi |
| 191 | + fi |
| 192 | + else |
| 193 | + echo "VM not responsive to commands yet" |
| 194 | + fi |
| 195 | + else |
| 196 | + echo "VM not in RUNNING state yet" |
| 197 | + fi |
| 198 | + |
| 199 | + echo "Waiting for VM to be ready... ($elapsed/$timeout seconds)" |
| 200 | + sleep 10 |
| 201 | + elapsed=$((elapsed + 10)) |
| 202 | + done |
| 203 | +
|
| 204 | + if [ $elapsed -ge $timeout ]; then |
| 205 | + echo "=== TIMEOUT DEBUGGING ===" |
| 206 | + echo "Final VM status:" |
| 207 | + sudo lxc list |
| 208 | + sudo lxc info $NAME |
| 209 | + echo "Timeout waiting for VM to be ready" |
| 210 | + exit 1 |
| 211 | + fi |
| 212 | +
|
| 213 | + echo "VM is ready for use!" |
| 214 | +
|
| 215 | + - name: Export VM IP for Kitchen (KITCHEN_HOST) |
| 216 | + run: | |
| 217 | + set -euo pipefail |
| 218 | + NAME="${{ matrix.suite }}-${{ env.PLATFORM }}" |
| 219 | +
|
| 220 | + # Wait up to 120s for an IP address to appear and scan all interfaces, not just eth0 |
| 221 | + timeout=120 |
| 222 | + interval=5 |
| 223 | + ipv4="" |
| 224 | + ipv6="" |
| 225 | + elapsed=0 |
| 226 | + while [ $elapsed -lt $timeout ]; do |
| 227 | + ipv4=$(sudo lxc list "$NAME" --format=json | jq -r '.[0].state.network | to_entries[] | .value.addresses[]? | select(.family=="inet" and .scope=="global") | .address' | head -n1 || true) |
| 228 | + ipv6=$(sudo lxc list "$NAME" --format=json | jq -r '.[0].state.network | to_entries[] | .value.addresses[]? | select(.family=="inet6" and .scope=="global") | .address' | head -n1 || true) |
| 229 | + if [ -n "$ipv4" ] || [ -n "$ipv6" ]; then |
| 230 | + break |
| 231 | + fi |
| 232 | + echo "Waiting for VM IP... ($elapsed/$timeout seconds)" |
| 233 | + sleep $interval |
| 234 | + elapsed=$((elapsed + interval)) |
| 235 | + done |
| 236 | +
|
| 237 | + # Fallback: query IPs from inside the VM if LXD hasn't reported them yet |
| 238 | + if [ -z "$ipv4" ] && [ -z "$ipv6" ]; then |
| 239 | + echo "Falling back to querying IPs from inside the VM..." |
| 240 | + set +e |
| 241 | + ipv4=$(sudo lxc exec "$NAME" -- bash -lc "ip -o -4 addr show scope global | awk '{print \\4}' | cut -d/ -f1 | head -n1" 2>/dev/null) |
| 242 | + ipv6=$(sudo lxc exec "$NAME" -- bash -lc "ip -o -6 addr show scope global | awk '{print \\4}' | cut -d/ -f1 | head -n1" 2>/dev/null) |
| 243 | + set -e |
| 244 | + fi |
| 245 | +
|
| 246 | + if [ -n "$ipv4" ]; then |
| 247 | + echo "Using IPv4 $ipv4 for KITCHEN_HOST" |
| 248 | + echo "KITCHEN_HOST=$ipv4" >> "$GITHUB_ENV" |
| 249 | + elif [ -n "$ipv6" ]; then |
| 250 | + echo "Using IPv6 $ipv6 for KITCHEN_HOST" |
| 251 | + echo "KITCHEN_HOST=$ipv6" >> "$GITHUB_ENV" |
| 252 | + else |
| 253 | + echo "Failed to discover a VM IP address" |
| 254 | + sudo lxc list "$NAME" |
| 255 | + sudo lxc info "$NAME" || true |
| 256 | + exit 1 |
| 257 | + fi |
| 258 | +
|
| 259 | + - name: Run kitchen test |
| 260 | + if: ${{ !contains(steps.commit.outputs.message, 'only-validate-profile') }} |
| 261 | + continue-on-error: true |
| 262 | + run: bundle exec kitchen test --destroy=always ${{ matrix.suite }}-${{ env.PLATFORM }} |
| 263 | + |
| 264 | + - name: Save our ${{ matrix.suite }} results summary |
| 265 | + continue-on-error: true |
| 266 | + if: ${{ !contains(steps.commit.outputs.message, 'only-validate-profile') }} |
| 267 | + uses: mitre/saf_action@v1.5.2 |
| 268 | + with: |
| 269 | + command_string: "view summary -j -i spec/results/${{ env.PLATFORM }}_${{ matrix.suite }}.json -o spec/results/${{ env.PLATFORM }}_${{ matrix.suite }}-data.json" |
| 270 | + |
| 271 | + - name: Save Test Result JSON |
| 272 | + if: ${{ !contains(steps.commit.outputs.message, 'only-validate-profile') }} |
| 273 | + uses: actions/upload-artifact@v4 |
| 274 | + with: |
| 275 | + name: ${{ env.PLATFORM }}_${{ matrix.suite }}.json |
| 276 | + path: spec/results/ |
| 277 | + |
| 278 | + - name: Upload ${{ matrix.suite }} to Heimdall |
| 279 | + if: ${{ !contains(steps.commit.outputs.message, 'only-validate-profile') }} |
| 280 | + continue-on-error: true |
| 281 | + run: | |
| 282 | + curl -# -s -F data=@spec/results/${{ env.PLATFORM }}_${{ matrix.suite }}.json -F "filename=${{ env.PLATFORM }}_${{ matrix.suite }}-${{ env.COMMIT_SHORT_SHA }}.json" -F "public=true" -F "evaluationTags=${{ env.COMMIT_SHORT_SHA }},${{ github.repository }},${{ github.workflow }}" -H "Authorization: Api-Key ${{ secrets.SAF_HEIMDALL_UPLOAD_KEY }}" "${{ vars.SAF_HEIMDALL_URL }}/evaluations" |
| 283 | +
|
| 284 | + - name: Display our ${{ matrix.suite }} results summary |
| 285 | + if: ${{ !contains(steps.commit.outputs.message, 'only-validate-profile') }} |
| 286 | + uses: mitre/saf_action@v1.5.2 |
| 287 | + with: |
| 288 | + command_string: "view summary -i spec/results/${{ env.PLATFORM }}_${{ matrix.suite }}.json" |
| 289 | + |
| 290 | + - name: Generate Markdown Summary |
| 291 | + continue-on-error: true |
| 292 | + id: generate-summary |
| 293 | + run: | |
| 294 | + cat spec/results/${{ env.PLATFORM }}_${{ matrix.suite }}-data.json | python markdown-summary.py > spec/results/${{ env.PLATFORM }}_${{ matrix.suite }}-markdown-summary.md |
| 295 | + cat spec/results/${{ env.PLATFORM }}_${{ matrix.suite }}-markdown-summary.md >> $GITHUB_STEP_SUMMARY |
| 296 | +
|
| 297 | + - name: Ensure the scan meets our ${{ matrix.suite }} results threshold |
| 298 | + if: ${{ !contains(steps.commit.outputs.message, 'only-validate-profile') }} |
| 299 | + uses: mitre/saf_action@v1.5.2 |
| 300 | + with: |
| 301 | + command_string: "validate threshold -i spec/results/${{ env.PLATFORM }}_${{ matrix.suite }}.json -F ${{ matrix.suite }}.threshold.yml" |
| 302 | + |
| 303 | + - name: Cleanup ephemeral SSH key |
| 304 | + if: always() |
| 305 | + run: | |
| 306 | + rm -f "$RUNNER_TEMP/kitchen_ed25519" "$RUNNER_TEMP/kitchen_ed25519.pub" |
0 commit comments