initial commit
deploy / deploy (push) Successful in 4m48s

Change-Id: If4e8a826d76819817e111cc7477a135f0c871db9
This commit is contained in:
Scaffolder
2026-05-13 18:29:35 +00:00
commit 59c9a95154
13 changed files with 791 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
.venv
__pycache__
*.pyc
+56
View File
@@ -0,0 +1,56 @@
name: cleanup-expired
on:
schedule:
- cron: "17 * * * *"
workflow_dispatch:
jobs:
cleanup-expired:
runs-on:
- toolzoo-host
env:
AWS_REGION: us-east-1
APP_STACK_PREFIX: temp-stack
TTL_GRACE_SECONDS: "600"
steps:
- name: Cleanup expired app stacks
shell: bash
run: |
set -euo pipefail
now_epoch="$(date -u +%s)"
prefix="${APP_STACK_PREFIX}-"
stacks="$(aws cloudformation list-stacks \
--stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE UPDATE_ROLLBACK_COMPLETE \
--query "StackSummaries[?starts_with(StackName, \`${prefix}\`)].StackName" \
--output text)"
for stack in $stacks; do
expires_at="$(aws cloudformation describe-stacks --stack-name "$stack" --query "Stacks[0].Tags[?Key=='ExpiresAt'].Value | [0]" --output text)"
owner_raw="$(aws cloudformation describe-stacks --stack-name "$stack" --query "Stacks[0].Tags[?Key=='Owner'].Value | [0]" --output text)"
repo_raw="$(aws cloudformation describe-stacks --stack-name "$stack" --query "Stacks[0].Tags[?Key=='Repository'].Value | [0]" --output text)"
if [[ "$expires_at" == "None" || -z "$expires_at" ]]; then
continue
fi
expires_epoch="$(date -u -d "$expires_at" +%s 2>/dev/null || echo 0)"
if [[ "$expires_epoch" -eq 0 ]]; then
continue
fi
if (( now_epoch >= expires_epoch + TTL_GRACE_SECONDS )); then
aws cloudformation delete-stack --stack-name "$stack"
aws cloudformation wait stack-delete-complete --stack-name "$stack"
if [[ "$owner_raw" != "None" && "$repo_raw" != "None" && -n "$owner_raw" && -n "$repo_raw" ]]; then
repo_slug="$(printf '%s' "$repo_raw" | tr '[:upper:]' '[:lower:]')"
ecr_repo="temp/${repo_slug}"
if aws ecr describe-repositories --repository-names "$ecr_repo" >/dev/null 2>&1; then
aws ecr delete-repository --repository-name "$ecr_repo" --force
fi
fi
fi
done
+217
View File
@@ -0,0 +1,217 @@
name: deploy
on:
push:
branches:
- main
workflow_dispatch:
inputs:
ttl:
description: "TTL for deploy operation (1h | 4h | 24h)"
required: false
default: 1h
jobs:
deploy:
runs-on:
- toolzoo-host
env:
BACKSTAGE_STACK_NAME: toolzoo-backstage
AWS_REGION: us-east-1
APP_STACK_PREFIX: temp-stack
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Derive deployment settings
shell: bash
run: |
set -euo pipefail
repo_full="${GITHUB_REPOSITORY}"
owner_raw="${repo_full%%/*}"
repo_raw="${repo_full##*/}"
repo_slug="$(printf '%s' "$repo_raw" | tr '[:upper:]' '[:lower:]')"
service_slug="${repo_slug}"
stack_name="${APP_STACK_PREFIX}-${repo_slug}"
account_id="$(aws sts get-caller-identity --query Account --output text)"
image_tag="${GITHUB_SHA:-manual}"
short_tag="${image_tag:0:12}"
registry="${account_id}.dkr.ecr.${AWS_REGION}.amazonaws.com"
ecr_repo="temp/${repo_slug}"
image_uri="${registry}/${ecr_repo}:${short_tag}"
techdocs_bucket="$(aws cloudformation describe-stacks --stack-name "$BACKSTAGE_STACK_NAME" --query "Stacks[0].Outputs[?OutputKey=='TechDocsBucketName'].OutputValue" --output text)"
ttl_input="${{ github.event.inputs.ttl }}"
case "${ttl_input:-}" in
""|"1h") ttl_hours="1" ;;
"4h") ttl_hours="4" ;;
"24h") ttl_hours="24" ;;
*) ttl_hours="1" ;;
esac
expires_at="$(date -u -d "+${ttl_hours} hour" +%Y-%m-%dT%H:%M:%SZ)"
{
echo "TOOLZOO_OWNER=${owner_raw}"
echo "TOOLZOO_REPO=${repo_raw}"
echo "TOOLZOO_STACK_NAME=${stack_name}"
echo "TOOLZOO_SERVICE_NAME=${service_slug}"
echo "TOOLZOO_ECR_REPO=${ecr_repo}"
echo "TOOLZOO_IMAGE_URI=${image_uri}"
echo "TOOLZOO_TECHDOCS_BUCKET=${techdocs_bucket}"
echo "TOOLZOO_EXPIRES_AT=${expires_at}"
} >> "$GITHUB_ENV"
- name: Ensure ECR repository exists
shell: bash
run: |
set -euo pipefail
aws ecr describe-repositories --repository-names "$TOOLZOO_ECR_REPO" >/dev/null 2>&1 || \
aws ecr create-repository \
--repository-name "$TOOLZOO_ECR_REPO" \
--image-scanning-configuration scanOnPush=true >/dev/null
- name: Log in to ECR
shell: bash
run: |
set -euo pipefail
aws ecr get-login-password --region "$AWS_REGION" | \
docker login --username AWS --password-stdin "${TOOLZOO_IMAGE_URI%/*}"
- name: Build and push container image
shell: bash
run: |
set -euo pipefail
docker build -t "$TOOLZOO_IMAGE_URI" .
docker push "$TOOLZOO_IMAGE_URI"
- name: Deploy ECS service stack
shell: bash
run: |
set -euo pipefail
aws cloudformation deploy \
--stack-name "$TOOLZOO_STACK_NAME" \
--template-file infra/cloudformation/service.yaml \
--capabilities CAPABILITY_NAMED_IAM \
--parameter-overrides \
EnvironmentName="$TOOLZOO_STACK_NAME" \
ContainerImage="$TOOLZOO_IMAGE_URI" \
ServiceName="$TOOLZOO_SERVICE_NAME" \
--tags \
Owner="$TOOLZOO_OWNER" \
Repository="$TOOLZOO_REPO" \
ExpiresAt="$TOOLZOO_EXPIRES_AT" \
ManagedBy="gitea-actions"
- name: Write Service URL to catalog metadata
shell: bash
run: |
set -euo pipefail
service_url="$(aws cloudformation describe-stacks --stack-name "$TOOLZOO_STACK_NAME" --query "Stacks[0].Outputs[?OutputKey=='ServiceUrl'].OutputValue" --output text)"
if [[ -z "$service_url" || "$service_url" == "None" ]]; then
echo "ServiceUrl output not found for stack $TOOLZOO_STACK_NAME"
exit 1
fi
printf '%s\n' \
' # BEGIN TOOLZOO MANAGED LINK' \
" - url: \"$service_url\"" \
' title: Service URL' \
' icon: web' \
' # END TOOLZOO MANAGED LINK' \
> /tmp/toolzoo-managed-link-item.yaml
awk '
BEGIN { in_block=0 }
/^ # BEGIN TOOLZOO MANAGED LINK$/ { in_block=1; next }
/^ # END TOOLZOO MANAGED LINK$/ { in_block=0; next }
in_block==1 { next }
{ print }
' catalog-info.yaml > /tmp/catalog-info.yaml.base
if grep -q '^ links:$' /tmp/catalog-info.yaml.base; then
awk '
BEGIN { inserted=0 }
{ print }
/^ links:$/ && inserted==0 {
while ((getline l < "/tmp/toolzoo-managed-link-item.yaml") > 0) {
print l
}
close("/tmp/toolzoo-managed-link-item.yaml")
inserted=1
}
' /tmp/catalog-info.yaml.base > /tmp/catalog-info.yaml.updated
else
awk '
BEGIN { inserted=0 }
/^ labels:$/ && inserted==0 {
print " links:"
while ((getline l < "/tmp/toolzoo-managed-link-item.yaml") > 0) {
print l
}
close("/tmp/toolzoo-managed-link-item.yaml")
inserted=1
}
/^ annotations:$/ && inserted==0 {
print " links:"
while ((getline l < "/tmp/toolzoo-managed-link-item.yaml") > 0) {
print l
}
close("/tmp/toolzoo-managed-link-item.yaml")
inserted=1
}
/^spec:$/ && inserted==0 {
print " links:"
while ((getline l < "/tmp/toolzoo-managed-link-item.yaml") > 0) {
print l
}
close("/tmp/toolzoo-managed-link-item.yaml")
inserted=1
}
{ print }
' /tmp/catalog-info.yaml.base > /tmp/catalog-info.yaml.updated
fi
mv /tmp/catalog-info.yaml.updated catalog-info.yaml
- name: Commit catalog metadata update
shell: bash
run: |
set -euo pipefail
if git diff --quiet -- catalog-info.yaml; then
echo "No catalog-info.yaml changes to commit."
exit 0
fi
git config user.name "toolzoo-bot"
git config user.email "toolzoo-bot@local"
git add catalog-info.yaml
git commit -m "Update catalog service URL from deployment output"
git push origin HEAD:main
- name: Publish TechDocs to S3
shell: bash
run: |
set -euo pipefail
python3 -m venv .techdocs-venv
. .techdocs-venv/bin/activate
python -m pip install --upgrade pip
python -m pip install mkdocs-techdocs-core
npx -y @techdocs/cli generate --no-docker
npx -y @techdocs/cli publish \
--publisher-type awsS3 \
--storage-name "$TOOLZOO_TECHDOCS_BUCKET" \
--entity "default/Component/${TOOLZOO_REPO}"
- name: Emit deployment summary
shell: bash
run: |
set -euo pipefail
: "${GITHUB_STEP_SUMMARY:=/tmp/toolzoo-step-summary}"
service_url="$(aws cloudformation describe-stacks --stack-name "$TOOLZOO_STACK_NAME" --query "Stacks[0].Outputs[?OutputKey=='ServiceUrl'].OutputValue" --output text)"
{
echo "Deployment stack: $TOOLZOO_STACK_NAME"
echo "Image: $TOOLZOO_IMAGE_URI"
echo "TechDocs bucket: $TOOLZOO_TECHDOCS_BUCKET"
echo "ExpiresAt (UTC): $TOOLZOO_EXPIRES_AT"
echo "Service URL: $service_url"
} | tee -a "$GITHUB_STEP_SUMMARY"
+43
View File
@@ -0,0 +1,43 @@
name: destroy
on:
workflow_dispatch:
inputs:
confirm_destroy:
description: "Type the repository name to confirm destroy"
required: true
default: ""
jobs:
destroy:
runs-on:
- toolzoo-host
env:
AWS_REGION: us-east-1
APP_STACK_PREFIX: temp-stack
steps:
- name: Destroy stack and ECR images
shell: bash
run: |
set -euo pipefail
repo_full="${GITHUB_REPOSITORY}"
owner_raw="${repo_full%%/*}"
repo_raw="${repo_full##*/}"
confirm="${{ github.event.inputs.confirm_destroy }}"
if [[ "$confirm" != "$repo_raw" ]]; then
echo "Refusing destroy: confirm_destroy must exactly match '$repo_raw'."
exit 1
fi
repo_slug="$(printf '%s' "$repo_raw" | tr '[:upper:]' '[:lower:]')"
stack_name="${APP_STACK_PREFIX}-${repo_slug}"
ecr_repo="temp/${repo_slug}"
aws cloudformation delete-stack --stack-name "$stack_name"
aws cloudformation wait stack-delete-complete --stack-name "$stack_name"
if aws ecr describe-repositories --repository-names "$ecr_repo" >/dev/null 2>&1; then
aws ecr delete-repository --repository-name "$ecr_repo" --force
fi
+14
View File
@@ -0,0 +1,14 @@
FROM public.ecr.aws/docker/library/python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PORT=8080
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"]
+27
View File
@@ -0,0 +1,27 @@
# sethtest
This repository was generated from the Backstage **Flask Web App** template.
## What you get
- `app.py` with `/` and `/health`
- `requirements.txt`
- Gunicorn production entrypoint
- Dockerfile exposing port 8080
- Backstage `catalog-info.yaml`
## Run locally
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python app.py
```
## Run with Docker
```bash
docker build -t sethtest .
docker run --rm -p 8080:8080 sethtest
```
+21
View File
@@ -0,0 +1,21 @@
from flask import Flask, jsonify, render_template
app = Flask(__name__)
@app.route("/health")
def health():
return jsonify(status="ok", service="sethtest"), 200
@app.route("/")
def index():
return render_template(
"index.html",
name="sethtest",
description="Seth doing stuff",
)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
+21
View File
@@ -0,0 +1,21 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: "sethtest"
description: "Seth doing stuff"
tags:
- flask
- python
- service
annotations:
toolzoo.io/created-by: "user:default/sharbeck@proofpoint.com"
backstage.io/techdocs-ref: dir:.
spec:
type: service
owner: "group:default/se"
lifecycle: experimental
+16
View File
@@ -0,0 +1,16 @@
# sethtest
## Overview
Seth doing stuff
## Operations
- Runtime port: `8080`
- Health check path: `/health`
- Deployment: Gitea Actions publishes a container image and updates the ECS service stack on pushes to `main`.
- After scaffold, track workflow in SETI Gitea -> Actions.
## Ownership
- Catalog owner: `group:default/se`
+311
View File
@@ -0,0 +1,311 @@
AWSTemplateFormatVersion: '2010-09-09'
Description: |
Tool Zoo application service stack. Builds a dedicated ECS Fargate web service
with an internet-facing ALB and a stable CloudFormation-managed URL.
Parameters:
EnvironmentName:
Type: String
AllowedPattern: ^[a-zA-Z0-9-]+$
Description: Prefix used for naming resources.
ServiceName:
Type: String
AllowedPattern: ^[a-z0-9-]+$
Description: Logical service name used for the ECS service and log group.
ContainerImage:
Type: String
Description: Fully-qualified container image URI to deploy.
ContainerPort:
Type: Number
Default: 8080
MinValue: 1
MaxValue: 65535
Description: Port exposed by the web container.
HealthCheckPath:
Type: String
Default: "/health"
Description: HTTP path used by the ALB health check.
DesiredCount:
Type: Number
Default: 1
MinValue: 1
MaxValue: 3
Description: Number of running tasks.
TaskCpu:
Type: Number
Default: 512
AllowedValues:
- 256
- 512
- 1024
- 2048
- 4096
Description: Fargate task CPU units.
TaskMemory:
Type: Number
Default: 1024
AllowedValues:
- 512
- 1024
- 2048
- 3072
- 4096
- 5120
- 6144
- 7168
- 8192
Description: Fargate task memory in MiB.
Mappings:
SubnetConfig:
Vpc:
CIDR: 10.52.0.0/16
PublicOne:
CIDR: 10.52.0.0/24
PublicTwo:
CIDR: 10.52.1.0/24
Resources:
Vpc:
Type: AWS::EC2::VPC
Properties:
CidrBlock: !FindInMap [SubnetConfig, Vpc, CIDR]
EnableDnsHostnames: true
EnableDnsSupport: true
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-vpc
InternetGateway:
Type: AWS::EC2::InternetGateway
Properties:
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-igw
VpcGatewayAttachment:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
InternetGatewayId: !Ref InternetGateway
VpcId: !Ref Vpc
PublicSubnetOne:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
AvailabilityZone: !Select [0, !GetAZs '']
CidrBlock: !FindInMap [SubnetConfig, PublicOne, CIDR]
MapPublicIpOnLaunch: true
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-public-a
PublicSubnetTwo:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
AvailabilityZone: !Select [1, !GetAZs '']
CidrBlock: !FindInMap [SubnetConfig, PublicTwo, CIDR]
MapPublicIpOnLaunch: true
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-public-b
PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref Vpc
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-public-rt
PublicDefaultRoute:
Type: AWS::EC2::Route
DependsOn: VpcGatewayAttachment
Properties:
RouteTableId: !Ref PublicRouteTable
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref InternetGateway
PublicSubnetOneRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PublicRouteTable
SubnetId: !Ref PublicSubnetOne
PublicSubnetTwoRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PublicRouteTable
SubnetId: !Ref PublicSubnetTwo
LoadBalancerSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: !Sub ${EnvironmentName} ALB ingress
VpcId: !Ref Vpc
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 80
ToPort: 80
CidrIp: 0.0.0.0/0
SecurityGroupEgress:
- IpProtocol: -1
CidrIp: 0.0.0.0/0
ServiceSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: !Sub ${EnvironmentName} ECS service ingress
VpcId: !Ref Vpc
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: !Ref ContainerPort
ToPort: !Ref ContainerPort
SourceSecurityGroupId: !Ref LoadBalancerSecurityGroup
SecurityGroupEgress:
- IpProtocol: -1
CidrIp: 0.0.0.0/0
ApplicationLoadBalancer:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Scheme: internet-facing
Type: application
SecurityGroups:
- !Ref LoadBalancerSecurityGroup
Subnets:
- !Ref PublicSubnetOne
- !Ref PublicSubnetTwo
ApplicationTargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
Port: !Ref ContainerPort
Protocol: HTTP
TargetType: ip
VpcId: !Ref Vpc
HealthCheckEnabled: true
HealthCheckPath: !Ref HealthCheckPath
HealthCheckProtocol: HTTP
Matcher:
HttpCode: 200-399
ApplicationListener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
DefaultActions:
- Type: forward
TargetGroupArn: !Ref ApplicationTargetGroup
LoadBalancerArn: !Ref ApplicationLoadBalancer
Port: 80
Protocol: HTTP
LogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub /aws/ecs/${EnvironmentName}/${ServiceName}
RetentionInDays: 14
Cluster:
Type: AWS::ECS::Cluster
Properties:
ClusterName: !Sub ${EnvironmentName}-cluster
TaskExecutionRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub ${EnvironmentName}-task-exec-role
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service:
- ecs-tasks.amazonaws.com
Action:
- sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
TaskRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub ${EnvironmentName}-task-role
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service:
- ecs-tasks.amazonaws.com
Action:
- sts:AssumeRole
TaskDefinition:
Type: AWS::ECS::TaskDefinition
Properties:
Family: !Sub ${EnvironmentName}-taskdef
Cpu: !Ref TaskCpu
Memory: !Ref TaskMemory
NetworkMode: awsvpc
RequiresCompatibilities:
- FARGATE
ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn
TaskRoleArn: !GetAtt TaskRole.Arn
ContainerDefinitions:
- Name: web
Image: !Ref ContainerImage
Essential: true
PortMappings:
- ContainerPort: !Ref ContainerPort
Protocol: tcp
Environment:
- Name: PORT
Value: !Sub '${ContainerPort}'
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !Ref LogGroup
awslogs-region: !Ref AWS::Region
awslogs-stream-prefix: web
Service:
Type: AWS::ECS::Service
DependsOn:
- ApplicationListener
Properties:
ServiceName: !Ref ServiceName
Cluster: !Ref Cluster
LaunchType: FARGATE
DesiredCount: !Ref DesiredCount
HealthCheckGracePeriodSeconds: 120
NetworkConfiguration:
AwsvpcConfiguration:
AssignPublicIp: ENABLED
SecurityGroups:
- !Ref ServiceSecurityGroup
Subnets:
- !Ref PublicSubnetOne
- !Ref PublicSubnetTwo
LoadBalancers:
- TargetGroupArn: !Ref ApplicationTargetGroup
ContainerName: web
ContainerPort: !Ref ContainerPort
TaskDefinition: !Ref TaskDefinition
Outputs:
ServiceUrl:
Description: Public URL for the deployed Tool Zoo service
Value: !Sub http://${ApplicationLoadBalancer.DNSName}
ClusterName:
Description: ECS cluster name for the deployed service
Value: !Ref Cluster
+8
View File
@@ -0,0 +1,8 @@
site_name: sethtest
site_description: Seth doing stuff
nav:
- Overview: docs/index.md
plugins:
- techdocs-core
+2
View File
@@ -0,0 +1,2 @@
Flask==3.0.3
gunicorn==23.0.0
+52
View File
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ name }}</title>
<style>
body {
margin: 0;
font-family: Arial, sans-serif;
color: #0f172a;
background: #f8fafc;
}
main {
max-width: 760px;
margin: 6rem auto;
padding: 0 1.5rem;
}
section {
background: #ffffff;
border: 1px solid #dbe3ee;
border-radius: 8px;
padding: 2rem;
}
h1 {
margin-top: 0;
}
p,
li {
line-height: 1.6;
color: #334155;
}
</style>
</head>
<body>
<main>
<section>
<h1>{{ name }}</h1>
<p>{{ description }}</p>
<ul>
<li>Backed by Flask and Gunicorn</li>
<li>Container listens on port 8080</li>
<li>Health check is available at <code>/health</code></li>
</ul>
</section>
</main>
</body>
</html>