Skip to content

Commit 228f2f3

Browse files
committed
core public build pipelines release
1 parent bfe32e2 commit 228f2f3

25 files changed

Lines changed: 2131 additions & 0 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
2+
## Create AWS Template (`actions/create-aws-template`)
3+
4+
### Overview
5+
6+
This action acts as the template compiler for our CloudFormation infrastructure. It takes a generic, region-agnostic meta-template and a JSON RegionMap, merges them using `jq`, and produces a valid, deployable CloudFormation JSON artifact.
7+
8+
### The Workflow
9+
10+
1. **Dependency Verification:** Ensures `jq` is installed on the runner.
11+
2. **Region Map Injection:** Reads the provided `region_map` string and injects it into the `.Mappings.RegionMap` block of the source meta-template. This overrides any placeholder mappings.
12+
3. **Lambda Code Injection:** For each entry in `lambda_injections`, reads the source file and injects its contents into `.Resources[<ResourceName>].Properties.Code.ZipFile`. Errors if the resource is missing from the template or the source file does not exist.
13+
4. **Artifact Publishing:** Uploads the newly generated CloudFormation template as a GitHub Actions artifact for downstream jobs (like deployment or release aggregation) to consume.
14+
15+
### Inputs & Outputs
16+
17+
| Input | Required | Description |
18+
| --- | --- | --- |
19+
| `region_map` | Yes | The JSON string containing region-to-AMI mappings. |
20+
| `output_filename` | Yes | The desired filename for the compiled template. |
21+
| `artifact_name` | Yes | The name to assign the uploaded GitHub Actions artifact (passed straight to `upload-artifact`'s `name`). This is an artifact label, not a filesystem path. |
22+
| `metatemplate_file_path` | Yes | Relative path to the source JSON meta-template. |
23+
| `lambda_injections` | No | JSON object mapping Lambda resource logical IDs to source files to embed. Defaults to `'{}'`. |
24+
25+
### Example Usage
26+
27+
```yaml
28+
- name: Generate CloudFormation Template
29+
uses: mathworks-ref-arch/iac-building-blocks/.github/actions/create-aws-template@main
30+
with:
31+
region_map: '{"us-east-1": {"AMI": "ami-0123456789abcdef0"}}'
32+
output_filename: 'R2025a-release-template.json'
33+
artifact_name: 'R2025a-release-template'
34+
metatemplate_file_path: 'src/meta-template.json'
35+
lambda_injections: '{"AttachInstanceProfileLambda": "src/attachinstanceprofile.py"}'
36+
37+
```
38+
39+
----
40+
41+
Copyright 2026 The MathWorks, Inc.
42+
43+
----
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Copyright 2026 The MathWorks, Inc.
2+
name: 'Create AWS Template'
3+
description: 'Generates a CloudFormation template by injecting a RegionMap.'
4+
5+
inputs:
6+
region_map:
7+
description: 'JSON object string for RegionMap'
8+
required: true
9+
output_filename:
10+
description: 'Filename for the generated template'
11+
required: true
12+
artifact_name:
13+
description: 'Name to assign the uploaded GitHub Actions artifact (an artifact label, not a filesystem path)'
14+
required: true
15+
metatemplate_file_path:
16+
description: 'Path to the source JSON meta-template file'
17+
required: true
18+
lambda_injections:
19+
description: |
20+
JSON object mapping Lambda resource logical IDs in the template to
21+
source files whose contents are injected into Code.ZipFile.
22+
Example: '{"AttachInstanceProfileLambda": "src/attachinstanceprofile.py"}'
23+
Pass '{}' or omit when the template has no Lambda code to inject.
24+
required: false
25+
default: '{}'
26+
27+
runs:
28+
using: "composite"
29+
steps:
30+
- name: Generate Template
31+
shell: bash
32+
env:
33+
TEMPLATE: ${{ inputs.metatemplate_file_path }}
34+
REGION_MAP: ${{ inputs.region_map }}
35+
OUT_NAME: ${{ inputs.output_filename }}
36+
LAMBDA_INJECTIONS: ${{ inputs.lambda_injections }}
37+
run: |
38+
if ! command -v jq &> /dev/null; then
39+
echo "jq not found, installing..."
40+
# Retry to ride out transient dpkg/apt lock contention on the runner.
41+
for i in 1 2 3 4 5; do
42+
sudo apt-get update && sudo apt-get install -y jq && break
43+
echo "apt-get failed (attempt $i), retrying in 10s..."
44+
sleep 10
45+
done
46+
fi
47+
if ! command -v jq &> /dev/null; then
48+
echo "::error::Failed to install jq"
49+
exit 1
50+
fi
51+
52+
if [ -z "$REGION_MAP" ]; then
53+
echo "::error::region_map input is empty"
54+
exit 1
55+
fi
56+
if [ ! -f "$TEMPLATE" ]; then
57+
echo "::error::Meta-template not found at: $TEMPLATE"
58+
exit 1
59+
fi
60+
if ! echo "$REGION_MAP" | jq empty 2>/dev/null; then
61+
echo "::error::region_map is not valid JSON: $REGION_MAP"
62+
exit 1
63+
fi
64+
65+
echo "Injecting Region Map into template..."
66+
echo "$REGION_MAP" > region_map_temp.json
67+
68+
jq --slurpfile map region_map_temp.json \
69+
'.Mappings.RegionMap = $map[0]' \
70+
"$TEMPLATE" > "$OUT_NAME"
71+
72+
rm region_map_temp.json
73+
74+
INJECTIONS="${LAMBDA_INJECTIONS:-{\}}"
75+
if ! echo "$INJECTIONS" | jq -e 'type == "object"' >/dev/null 2>&1; then
76+
echo "::error::lambda_injections is not a valid JSON object: $INJECTIONS"
77+
exit 1
78+
fi
79+
80+
while IFS=$'\t' read -r resource code_file; do
81+
[ -z "$resource" ] && continue
82+
if [ ! -f "$code_file" ]; then
83+
echo "::error::Lambda code file not found for $resource: $code_file"
84+
exit 1
85+
fi
86+
if ! jq -e --arg r "$resource" '.Resources[$r]' "$OUT_NAME" >/dev/null; then
87+
echo "::error::Resource $resource not found in template $OUT_NAME"
88+
exit 1
89+
fi
90+
echo "Injecting Lambda code into $resource from $code_file..."
91+
jq --arg r "$resource" --rawfile code "$code_file" \
92+
'.Resources[$r].Properties.Code.ZipFile = $code' \
93+
"$OUT_NAME" > "${OUT_NAME}.tmp"
94+
mv "${OUT_NAME}.tmp" "$OUT_NAME"
95+
done < <(echo "$INJECTIONS" | jq -r 'to_entries[] | "\(.key)\t\(.value)"')
96+
97+
echo "Generated: $OUT_NAME"
98+
99+
- name: Upload Artifact
100+
uses: actions/upload-artifact@v5
101+
with:
102+
name: ${{ inputs.artifact_name }}
103+
path: ${{ inputs.output_filename }}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
2+
## Deploy CFN (`actions/deploy-cfn`)
3+
4+
### Overview
5+
6+
This action deploys an ephemeral CloudFormation stack for integration and smoke testing. To avoid massive, brittle conditional blocks in our workflow YAMLs, this action utilizes a "Flavor Mapping" pattern. It dynamically constructs CloudFormation parameters based on the specific architecture being deployed (e.g., Linux vs. Windows, Single Node vs. Parallel Server).
7+
8+
### The Workflow
9+
10+
1. **Type Resolution:** Reads `refarch-type-mappings.json` to lookup the default parameter values and variable names (like identifying whether to use `SSHKeyName` or `RDPKeyName`) for the requested `refarch_type`.
11+
2. **Dynamic Parameter Injection:** Fetches the GitHub Runner's current IP address and injects it, along with standard inputs (VPC, Subnet, SSH Key), into a generated `params.json` file.
12+
3. **Execution:** Triggers `aws cloudformation create-stack` and blocks until the `stack-create-complete` signal is received.
13+
4. **Data Retrieval:** Parses the final CloudFormation stack outputs into a flattened JSON key-value string.
14+
15+
### Inputs & Outputs
16+
17+
| Input | Required | Description |
18+
| --- | --- | --- |
19+
| `compiled_template_file_path` | Yes | Path to the already-compiled CloudFormation template. |
20+
| `refarch_type` | Yes | Target deployment architecture type matching a key in `refarch-type-mappings.json`. |
21+
| `region` | Yes | AWS region to deploy the stack into. |
22+
| `stack_name` | Yes | Unique identifier for the temporary stack. |
23+
| `vpc_id` / `subnet_id` | Yes | Network configuration for the deployment. |
24+
| `key_pair_name` | Yes | Name of the pre-provisioned AWS EC2 key pair for access. |
25+
26+
**Outputs:**
27+
28+
* `stack_outputs`: A flat JSON string of the CloudFormation stack outputs (e.g., `{"HeadnodePublicDNS": "ec2-...", "RDPConnection": "..."}`).
29+
30+
### Example Usage
31+
32+
```yaml
33+
- name: Deploy Ephemeral Stack
34+
id: deploy
35+
uses: mathworks-ref-arch/iac-building-blocks/.github/actions/deploy-cfn@main
36+
with:
37+
compiled_template_file_path: './R2025a-test-template.json'
38+
refarch_type: 'matlab-linux'
39+
region: 'us-east-1'
40+
stack_name: 'smoke-test-matlab-linux-R2025a-${{ github.run_id }}'
41+
vpc_id: 'vpc-0abcd1234'
42+
subnet_id: 'subnet-0abcd1234'
43+
key_pair_name: 'smoke-test-key'
44+
45+
```
46+
47+
----
48+
49+
Copyright 2026 The MathWorks, Inc.
50+
51+
----
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# Copyright 2026 The MathWorks, Inc.
2+
name: 'Deploy CloudFormation Stack'
3+
description: 'Downloads template, maps parameters, and deploys stack'
4+
5+
inputs:
6+
compiled_template_file_path:
7+
description: "Path to the already-compiled CloudFormation template to deploy"
8+
required: true
9+
refarch_type:
10+
description: "Deployment architecture type (e.g., ps-linux, matlab-linux)"
11+
required: true
12+
region:
13+
description: "AWS region to deploy the stack into"
14+
required: true
15+
stack_name:
16+
description: "Unique name for the temporary CloudFormation stack"
17+
required: true
18+
vpc_id:
19+
description: "VPC to deploy the stack into"
20+
required: true
21+
subnet_id:
22+
description: "Subnet to deploy the stack into"
23+
required: true
24+
key_pair_name:
25+
description: "Name of the pre-provisioned AWS EC2 key pair for instance access"
26+
required: true
27+
28+
outputs:
29+
stack_outputs:
30+
description: "JSON string of stack outputs"
31+
value: ${{ steps.outputs.outputs.STACK_OUTPUTS }}
32+
33+
runs:
34+
using: "composite"
35+
steps:
36+
- name: Validate Inputs
37+
shell: bash
38+
env:
39+
REFARCH_TYPE: ${{ inputs.refarch_type }}
40+
CONFIG_FILE: "${{ github.action_path }}/refarch-type-mappings.json"
41+
run: |
42+
if [ ! -f "$CONFIG_FILE" ]; then
43+
echo "::error::refarch-type-mappings.json not found at $CONFIG_FILE"
44+
exit 1
45+
fi
46+
if ! jq -e ".\"$REFARCH_TYPE\"" "$CONFIG_FILE" > /dev/null 2>&1; then
47+
AVAILABLE=$(jq -r 'keys | join(", ")' "$CONFIG_FILE")
48+
echo "::error::Unknown refarch_type '$REFARCH_TYPE'. Available types: $AVAILABLE"
49+
exit 1
50+
fi
51+
52+
- name: Get Runner Public IP
53+
id: get-ip
54+
shell: bash
55+
run: |
56+
IP=$(curl -fsS --retry 3 --max-time 10 https://checkip.amazonaws.com | tr -d '[:space:]')
57+
if ! [[ "$IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
58+
echo "::error::Failed to resolve runner public IPv4 (got: '$IP')"
59+
exit 1
60+
fi
61+
echo "ipv4=$IP" >> "$GITHUB_OUTPUT"
62+
63+
- name: Generate Stack Password
64+
id: gen-password
65+
shell: bash
66+
run: |
67+
# Build a 20-char password that satisfies all known MATLAB / NLM regexes:
68+
# >=14 chars, includes upper, lower, digit, and special character.
69+
UPPER=$(LC_ALL=C tr -dc 'A-Z' < /dev/urandom | head -c1)
70+
LOWER=$(LC_ALL=C tr -dc 'a-z' < /dev/urandom | head -c1)
71+
DIGIT=$(LC_ALL=C tr -dc '0-9' < /dev/urandom | head -c1)
72+
SPECIAL=$(LC_ALL=C tr -dc '!@#%^_+=-' < /dev/urandom | head -c1)
73+
REST=$(LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c16)
74+
PASSWORD="${UPPER}${LOWER}${DIGIT}${SPECIAL}${REST}"
75+
echo "::add-mask::$PASSWORD"
76+
echo "password=$PASSWORD" >> "$GITHUB_OUTPUT"
77+
78+
- name: Prepare Parameters JSON
79+
id: prep-params
80+
shell: bash
81+
env:
82+
REFARCH_TYPE: ${{ inputs.refarch_type }}
83+
VPC: ${{ inputs.vpc_id }}
84+
SUBNET: ${{ inputs.subnet_id }}
85+
KEY: ${{ inputs.key_pair_name }}
86+
CLIENT_IP: "${{ steps.get-ip.outputs.ipv4 }}/32"
87+
STACK_PASSWORD: ${{ steps.gen-password.outputs.password }}
88+
CONFIG_FILE: "${{ github.action_path }}/refarch-type-mappings.json"
89+
run: |
90+
KEY_PARAM=$(jq -r ".\"$REFARCH_TYPE\".key_param" $CONFIG_FILE)
91+
VPC_PARAM=$(jq -r ".\"$REFARCH_TYPE\".vpc_param" $CONFIG_FILE)
92+
SUBNET_PARAM=$(jq -r ".\"$REFARCH_TYPE\".subnet_param" $CONFIG_FILE)
93+
CLIENT_IP_PARAM=$(jq -r ".\"$REFARCH_TYPE\".client_ip_param" $CONFIG_FILE)
94+
95+
jq ".\"$REFARCH_TYPE\".defaults" $CONFIG_FILE > params.json
96+
97+
jq --arg k "$VPC_PARAM" --arg v "$VPC" '. += [{"ParameterKey": $k, "ParameterValue": $v}]' params.json > tmp.json && mv tmp.json params.json
98+
jq --arg k "$SUBNET_PARAM" --arg v "$SUBNET" '. += [{"ParameterKey": $k, "ParameterValue": $v}]' params.json > tmp.json && mv tmp.json params.json
99+
jq --arg k "$KEY_PARAM" --arg v "$KEY" '. += [{"ParameterKey": $k, "ParameterValue": $v}]' params.json > tmp.json && mv tmp.json params.json
100+
jq --arg k "$CLIENT_IP_PARAM" --arg v "$CLIENT_IP" '. += [{"ParameterKey": $k, "ParameterValue": $v}]' params.json > tmp.json && mv tmp.json params.json
101+
102+
# Inject a random password for any template that asks for one. We
103+
# set Password / ConfirmPassword (and never log them) so deployments
104+
# do not rely on a hardcoded credential.
105+
jq --arg pw "$STACK_PASSWORD" '
106+
(.[] | select(.ParameterKey == "Password") | .ParameterValue) |= $pw
107+
| (.[] | select(.ParameterKey == "ConfirmPassword") | .ParameterValue) |= $pw
108+
' params.json > tmp.json && mv tmp.json params.json
109+
110+
echo "Generated Parameters (passwords redacted by runner masking):"
111+
cat params.json
112+
113+
- name: Deploy Stack
114+
shell: bash
115+
run: |
116+
TEMPLATE_FILE="${{ inputs.compiled_template_file_path }}"
117+
118+
if [[ ! -f "$TEMPLATE_FILE" ]]; then
119+
echo "::error::Template file not found at $TEMPLATE_FILE"
120+
exit 1
121+
fi
122+
123+
aws cloudformation create-stack \
124+
--stack-name ${{ inputs.stack_name }} \
125+
--template-body file://"$TEMPLATE_FILE" \
126+
--region ${{ inputs.region }} \
127+
--capabilities CAPABILITY_NAMED_IAM CAPABILITY_AUTO_EXPAND \
128+
--parameters file://params.json
129+
130+
echo "Waiting for stack creation..."
131+
if ! aws cloudformation wait stack-create-complete \
132+
--stack-name ${{ inputs.stack_name }} \
133+
--region ${{ inputs.region }} 2>&1; then
134+
echo "::error::CloudFormation stack creation failed for ${{ inputs.stack_name }}"
135+
aws cloudformation describe-stack-events \
136+
--stack-name ${{ inputs.stack_name }} \
137+
--region ${{ inputs.region }} \
138+
--query "StackEvents[?ResourceStatus=='CREATE_FAILED'].[LogicalResourceId,ResourceStatusReason]" \
139+
--output table
140+
exit 1
141+
fi
142+
143+
- name: Retrieve Stack Outputs
144+
id: outputs
145+
shell: bash
146+
run: |
147+
# Get Outputs as a simplified JSON object { "OutputKey": "OutputValue" }
148+
OUTPUTS=$(aws cloudformation describe-stacks \
149+
--stack-name ${{ inputs.stack_name }} \
150+
--region ${{ inputs.region }} \
151+
--query "Stacks[0].Outputs" | \
152+
jq -c 'reduce .[] as $item ({}; . + {($item.OutputKey): $item.OutputValue})')
153+
154+
echo "STACK_OUTPUTS=$OUTPUTS" >> $GITHUB_OUTPUT

0 commit comments

Comments
 (0)