mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 06:43:12 +00:00
ci(workflows): call org reusable CI and Fargate CD (#276)
* ci(workflows): call org reusable CI and Fargate CD Local CI and the image deploy duplicated the org workflows and still required ci / ci. Pin the callers to those workflows and trust the reusable deploy ref. * test(ci): probe ruff with an undefined name * test(ci): remove the undefined-name ruff probe * ci: retrigger checks after removing the ruff probe * test(ci): probe ruff with an unused import * style: apply formatter * test(ci): remove the autofix probe --------- Co-authored-by: sea-haven-auto-fix[bot] <332630863+sea-haven-auto-fix[bot]@users.noreply.github.com>
This commit is contained in:
parent
e600dd47a3
commit
8a23d06a64
18 changed files with 224 additions and 335 deletions
94
.github/workflows/ci.yaml
vendored
94
.github/workflows/ci.yaml
vendored
|
|
@ -2,15 +2,35 @@ name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main, hotfix/**, release/**]
|
||||||
merge_group:
|
merge_group:
|
||||||
|
push:
|
||||||
|
branches: [hotfix/**, release/**]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pytest:
|
autofix:
|
||||||
name: Pytest
|
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
|
||||||
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
presets: ruff,terraform
|
||||||
|
terraform-version: "1.16.0"
|
||||||
|
|
||||||
|
lint:
|
||||||
|
needs: autofix
|
||||||
|
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||||
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
test:
|
||||||
|
needs: autofix
|
||||||
|
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -37,33 +57,16 @@ jobs:
|
||||||
run: pytest
|
run: pytest
|
||||||
|
|
||||||
terraform:
|
terraform:
|
||||||
name: Terraform
|
needs: autofix
|
||||||
runs-on: ubuntu-latest
|
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||||
timeout-minutes: 15
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: terraform
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
terraform-version: "1.16.0"
|
||||||
|
|
||||||
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
|
||||||
with:
|
|
||||||
terraform_version: "1.16.0"
|
|
||||||
terraform_wrapper: false
|
|
||||||
|
|
||||||
- name: Terraform fmt
|
|
||||||
run: terraform fmt -check -recursive
|
|
||||||
|
|
||||||
- name: Terraform init
|
|
||||||
run: terraform init -backend=false
|
|
||||||
|
|
||||||
- name: Terraform validate
|
|
||||||
run: terraform validate
|
|
||||||
|
|
||||||
openapi:
|
openapi:
|
||||||
name: OpenAPI
|
name: OpenAPI
|
||||||
|
needs: autofix
|
||||||
|
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -82,35 +85,22 @@ jobs:
|
||||||
- name: Lint OpenAPI
|
- name: Lint OpenAPI
|
||||||
run: npm run openapi:lint
|
run: npm run openapi:lint
|
||||||
|
|
||||||
ci:
|
ci-complete:
|
||||||
name: ci / ci
|
name: ci-complete
|
||||||
needs: [pytest, terraform, openapi]
|
needs: [autofix, lint, test, terraform, openapi]
|
||||||
if: ${{ always() && !cancelled() }}
|
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Check jobs
|
- name: Require portions
|
||||||
env:
|
env:
|
||||||
PYTEST_RESULT: ${{ needs.pytest.result }}
|
LINT: ${{ needs.lint.result }}
|
||||||
TERRAFORM_RESULT: ${{ needs.terraform.result }}
|
TEST: ${{ needs.test.result }}
|
||||||
OPENAPI_RESULT: ${{ needs.openapi.result }}
|
TERRAFORM: ${{ needs.terraform.result }}
|
||||||
|
OPENAPI: ${{ needs.openapi.result }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
fail=0
|
test "${LINT}" = success
|
||||||
check() {
|
test "${TEST}" = success
|
||||||
local name="$1"
|
test "${TERRAFORM}" = success
|
||||||
local result="$2"
|
test "${OPENAPI}" = success
|
||||||
case "${result}" in
|
|
||||||
success)
|
|
||||||
echo "${name}: ${result}"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "${name}: ${result}" >&2
|
|
||||||
fail=1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
check pytest "${PYTEST_RESULT}"
|
|
||||||
check terraform "${TERRAFORM_RESULT}"
|
|
||||||
check openapi "${OPENAPI_RESULT}"
|
|
||||||
exit "${fail}"
|
|
||||||
|
|
|
||||||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
|
|
@ -7,4 +7,4 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
review:
|
review:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||||
|
|
|
||||||
225
.github/workflows/deploy-api.yaml
vendored
225
.github/workflows/deploy-api.yaml
vendored
|
|
@ -1,8 +1,8 @@
|
||||||
name: Deploy API
|
name: Deploy API
|
||||||
|
|
||||||
# Fargate image CD (PLAT-216). GitHub Actions builds the Flask image, pushes
|
# Fargate image CD (PLAT-216). GitHub Actions builds the Flask image, pushes to ECR,
|
||||||
# to ECR, and registers a new task definition. Terraform owns the cluster,
|
# and registers a new task definition. Terraform owns the cluster, service,
|
||||||
# service, ALB, and ignores container_definitions / task_definition.
|
# ALB, and ignores container_definitions / task_definition.
|
||||||
#
|
#
|
||||||
# push to main -> dev, at github.sha
|
# push to main -> dev, at github.sha
|
||||||
# release: published -> prod, at the release tag
|
# release: published -> prod, at the release tag
|
||||||
|
|
@ -40,206 +40,31 @@ permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
target:
|
deploy-dev:
|
||||||
name: Resolve target
|
name: Deploy API to dev
|
||||||
runs-on: ubuntu-latest
|
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev')
|
||||||
timeout-minutes: 5
|
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||||
outputs:
|
|
||||||
environment: ${{ steps.resolve.outputs.environment }}
|
|
||||||
ref: ${{ steps.resolve.outputs.ref }}
|
|
||||||
steps:
|
|
||||||
- id: resolve
|
|
||||||
env:
|
|
||||||
EVENT_NAME: ${{ github.event_name }}
|
|
||||||
GITHUB_REF_NAME_IN: ${{ github.ref }}
|
|
||||||
GITHUB_SHA_IN: ${{ github.sha }}
|
|
||||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
INPUT_ENVIRONMENT: ${{ inputs.environment }}
|
|
||||||
INPUT_REF: ${{ inputs.ref }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
case "${EVENT_NAME}" in
|
|
||||||
push)
|
|
||||||
if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then
|
|
||||||
echo "push deploys only run from main" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
environment=dev
|
|
||||||
ref="${GITHUB_SHA_IN}"
|
|
||||||
;;
|
|
||||||
release)
|
|
||||||
environment=prod
|
|
||||||
ref="${RELEASE_TAG}"
|
|
||||||
status="$(gh api "repos/${REPO}/compare/main...${RELEASE_TAG}" --jq .status)"
|
|
||||||
if [ "${status}" != "behind" ] && [ "${status}" != "identical" ]; then
|
|
||||||
echo "release tag ${RELEASE_TAG} is not on main (compare status: ${status})" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
workflow_dispatch)
|
|
||||||
environment="${INPUT_ENVIRONMENT}"
|
|
||||||
ref="${INPUT_REF:-${GITHUB_SHA_IN}}"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "unsupported event ${EVENT_NAME}" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
{
|
|
||||||
echo "environment=${environment}"
|
|
||||||
echo "ref=${ref}"
|
|
||||||
} >> "${GITHUB_OUTPUT}"
|
|
||||||
echo "Deploying ${ref} to ${environment}"
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
name: Deploy API to ${{ needs.target.outputs.environment }}
|
|
||||||
needs: target
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
environment: ${{ needs.target.outputs.environment }}
|
|
||||||
concurrency:
|
|
||||||
group: deploy-api-${{ needs.target.outputs.environment }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
env:
|
secrets: inherit
|
||||||
AWS_REGION: us-east-1
|
|
||||||
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.target.outputs.ref }}
|
environment: dev
|
||||||
persist-credentials: false
|
ref: ${{ inputs.ref }}
|
||||||
|
ssm-prefix: /afterhours-shift-manager/deploy
|
||||||
|
docker-platform: linux/arm64
|
||||||
|
|
||||||
- name: Resolve commit
|
deploy-prod:
|
||||||
id: commit
|
name: Deploy API to prod
|
||||||
run: |
|
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod')
|
||||||
set -euo pipefail
|
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||||
sha="$(git rev-parse HEAD)"
|
permissions:
|
||||||
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
|
contents: read
|
||||||
echo "Building ${sha}"
|
id-token: write
|
||||||
|
secrets: inherit
|
||||||
- name: Configure AWS credentials using OIDC
|
|
||||||
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
|
|
||||||
with:
|
with:
|
||||||
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
|
environment: prod
|
||||||
aws-region: us-east-1
|
ref: ${{ github.event.release.tag_name || inputs.ref }}
|
||||||
audience: sts.amazonaws.com
|
ssm-prefix: /afterhours-shift-manager/deploy
|
||||||
|
docker-platform: linux/arm64
|
||||||
- name: Get deploy parameters
|
ship-gate: true
|
||||||
id: deploy
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
get_param() {
|
|
||||||
aws ssm get-parameter --name "$1" --query Parameter.Value --output text
|
|
||||||
}
|
|
||||||
CLUSTER=$(get_param /afterhours-shift-manager/deploy/cluster)
|
|
||||||
SERVICE=$(get_param /afterhours-shift-manager/deploy/service)
|
|
||||||
FAMILY=$(get_param /afterhours-shift-manager/deploy/task-family)
|
|
||||||
ECR=$(get_param /afterhours-shift-manager/deploy/ecr-repository)
|
|
||||||
CONTAINER=$(get_param /afterhours-shift-manager/deploy/container-name)
|
|
||||||
API_URL=$(get_param /afterhours-shift-manager/deploy/api-url)
|
|
||||||
{
|
|
||||||
echo "cluster=${CLUSTER}"
|
|
||||||
echo "service=${SERVICE}"
|
|
||||||
echo "family=${FAMILY}"
|
|
||||||
echo "ecr=${ECR}"
|
|
||||||
echo "container=${CONTAINER}"
|
|
||||||
echo "api_url=${API_URL}"
|
|
||||||
} >> "${GITHUB_OUTPUT}"
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
|
||||||
with:
|
|
||||||
platforms: arm64
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
|
||||||
|
|
||||||
- name: Login to Amazon ECR
|
|
||||||
uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7
|
|
||||||
|
|
||||||
- name: Build and push image
|
|
||||||
env:
|
|
||||||
ECR: ${{ steps.deploy.outputs.ecr }}
|
|
||||||
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
|
||||||
ENVIRONMENT: ${{ needs.target.outputs.environment }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
docker buildx build \
|
|
||||||
--platform linux/arm64 \
|
|
||||||
--build-arg "GIT_SHA=${GIT_SHA}" \
|
|
||||||
-t "${ECR}:${GIT_SHA}" \
|
|
||||||
-t "${ECR}:${ENVIRONMENT}" \
|
|
||||||
--push \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Register task definition and update service
|
|
||||||
env:
|
|
||||||
CLUSTER: ${{ steps.deploy.outputs.cluster }}
|
|
||||||
SERVICE: ${{ steps.deploy.outputs.service }}
|
|
||||||
FAMILY: ${{ steps.deploy.outputs.family }}
|
|
||||||
CONTAINER: ${{ steps.deploy.outputs.container }}
|
|
||||||
IMAGE: ${{ steps.deploy.outputs.ecr }}:${{ steps.commit.outputs.sha }}
|
|
||||||
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
aws ecs describe-task-definition \
|
|
||||||
--task-definition "${FAMILY}" \
|
|
||||||
--query taskDefinition \
|
|
||||||
--output json \
|
|
||||||
| python3 -c '
|
|
||||||
import json, os, sys
|
|
||||||
td = json.load(sys.stdin)
|
|
||||||
for key in (
|
|
||||||
"taskDefinitionArn",
|
|
||||||
"revision",
|
|
||||||
"status",
|
|
||||||
"requiresAttributes",
|
|
||||||
"compatibilities",
|
|
||||||
"registeredAt",
|
|
||||||
"registeredBy",
|
|
||||||
"deregisteredAt",
|
|
||||||
):
|
|
||||||
td.pop(key, None)
|
|
||||||
image = os.environ["IMAGE"]
|
|
||||||
sha = os.environ["GIT_SHA"]
|
|
||||||
name = os.environ["CONTAINER"]
|
|
||||||
for container in td["containerDefinitions"]:
|
|
||||||
if container["name"] != name:
|
|
||||||
continue
|
|
||||||
container["image"] = image
|
|
||||||
env = {item["name"]: item["value"] for item in container.get("environment", [])}
|
|
||||||
env["GIT_SHA"] = sha
|
|
||||||
container["environment"] = [{"name": key, "value": value} for key, value in env.items()]
|
|
||||||
container.pop("command", None)
|
|
||||||
json.dump(td, sys.stdout)
|
|
||||||
' > /tmp/task-def.json
|
|
||||||
REV="$(aws ecs register-task-definition --cli-input-json file:///tmp/task-def.json --query taskDefinition.revision --output text)"
|
|
||||||
aws ecs update-service \
|
|
||||||
--cluster "${CLUSTER}" \
|
|
||||||
--service "${SERVICE}" \
|
|
||||||
--task-definition "${FAMILY}:${REV}" \
|
|
||||||
--force-new-deployment \
|
|
||||||
>/dev/null
|
|
||||||
aws ecs wait services-stable --cluster "${CLUSTER}" --services "${SERVICE}"
|
|
||||||
|
|
||||||
- name: Verify health SHA
|
|
||||||
env:
|
|
||||||
API_URL: ${{ steps.deploy.outputs.api_url }}
|
|
||||||
EXPECTED_SHA: ${{ steps.commit.outputs.sha }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
for _ in 1 2 3 4 5 6; do
|
|
||||||
BODY="$(curl -fsS "${API_URL}/api/health" || true)"
|
|
||||||
echo "${BODY}"
|
|
||||||
if echo "${BODY}" | python3 -c 'import json,os,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("sha")==os.environ["EXPECTED_SHA"] else 1)'; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 10
|
|
||||||
done
|
|
||||||
echo "health SHA did not match ${EXPECTED_SHA}" >&2
|
|
||||||
exit 1
|
|
||||||
|
|
|
||||||
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
|
|
@ -10,4 +10,4 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
label:
|
label:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,16 @@ def main() -> int:
|
||||||
|
|
||||||
src = _client(args.src_profile, args.region)
|
src = _client(args.src_profile, args.region)
|
||||||
dst = _client(args.dst_profile, args.region)
|
dst = _client(args.dst_profile, args.region)
|
||||||
src_id = boto3.Session(profile_name=args.src_profile).client("sts").get_caller_identity()["Account"]
|
src_id = (
|
||||||
dst_id = boto3.Session(profile_name=args.dst_profile).client("sts").get_caller_identity()["Account"]
|
boto3.Session(profile_name=args.src_profile)
|
||||||
|
.client("sts")
|
||||||
|
.get_caller_identity()["Account"]
|
||||||
|
)
|
||||||
|
dst_id = (
|
||||||
|
boto3.Session(profile_name=args.dst_profile)
|
||||||
|
.client("sts")
|
||||||
|
.get_caller_identity()["Account"]
|
||||||
|
)
|
||||||
if src_id != SRC_ACCOUNT:
|
if src_id != SRC_ACCOUNT:
|
||||||
print(f"src account {src_id} is not mgmt {SRC_ACCOUNT}", file=sys.stderr)
|
print(f"src account {src_id} is not mgmt {SRC_ACCOUNT}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
|
||||||
|
|
@ -35,11 +35,17 @@ _NO_VALUE_CODES = frozenset({"ResourceNotFoundException", "InvalidRequestExcepti
|
||||||
|
|
||||||
|
|
||||||
def _client(profile: str, region: str):
|
def _client(profile: str, region: str):
|
||||||
return boto3.Session(profile_name=profile, region_name=region).client("secretsmanager")
|
return boto3.Session(profile_name=profile, region_name=region).client(
|
||||||
|
"secretsmanager"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _account(profile: str) -> str:
|
def _account(profile: str) -> str:
|
||||||
return boto3.Session(profile_name=profile).client("sts").get_caller_identity()["Account"]
|
return (
|
||||||
|
boto3.Session(profile_name=profile)
|
||||||
|
.client("sts")
|
||||||
|
.get_caller_identity()["Account"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def secret_string(client, name: str) -> str | None:
|
def secret_string(client, name: str) -> str | None:
|
||||||
|
|
@ -72,10 +78,15 @@ def copy_secrets(src, dst, *, execute: bool) -> int:
|
||||||
for name in VERIFY_ONLY:
|
for name in VERIFY_ONLY:
|
||||||
value = secret_string(dst, name)
|
value = secret_string(dst, name)
|
||||||
if value is None:
|
if value is None:
|
||||||
print(f"missing prod secret {name} (expected from PLAT-76)", file=sys.stderr)
|
print(
|
||||||
|
f"missing prod secret {name} (expected from PLAT-76)", file=sys.stderr
|
||||||
|
)
|
||||||
rc = 1
|
rc = 1
|
||||||
elif not value.strip():
|
elif not value.strip():
|
||||||
print(f"empty prod 3cx secret {name} (do not overwrite from mgmt)", file=sys.stderr)
|
print(
|
||||||
|
f"empty prod 3cx secret {name} (do not overwrite from mgmt)",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
rc = 1
|
rc = 1
|
||||||
else:
|
else:
|
||||||
print(f"keep existing prod secret {name}")
|
print(f"keep existing prod secret {name}")
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ from botocore.exceptions import ClientError
|
||||||
|
|
||||||
SRC_ACCOUNT = "328440206208"
|
SRC_ACCOUNT = "328440206208"
|
||||||
DST_ACCOUNT = "011934824531"
|
DST_ACCOUNT = "011934824531"
|
||||||
PROD_ROUTER_ARN = "arn:aws:lambda:us-east-1:011934824531:function:afterhours-holiday-router"
|
PROD_ROUTER_ARN = (
|
||||||
|
"arn:aws:lambda:us-east-1:011934824531:function:afterhours-holiday-router"
|
||||||
|
)
|
||||||
PROD_ROLE_ARN = "arn:aws:iam::011934824531:role/tf-managed/afterhours-shift-manager-holiday-scheduler"
|
PROD_ROLE_ARN = "arn:aws:iam::011934824531:role/tf-managed/afterhours-shift-manager-holiday-scheduler"
|
||||||
PREFIXES = ("holiday-activate-", "holiday-deactivate-")
|
PREFIXES = ("holiday-activate-", "holiday-deactivate-")
|
||||||
_AT = re.compile(r"^at\((\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\)$")
|
_AT = re.compile(r"^at\((\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\)$")
|
||||||
|
|
@ -30,7 +32,11 @@ def _client(profile: str, region: str):
|
||||||
|
|
||||||
|
|
||||||
def _account(profile: str) -> str:
|
def _account(profile: str) -> str:
|
||||||
return boto3.Session(profile_name=profile).client("sts").get_caller_identity()["Account"]
|
return (
|
||||||
|
boto3.Session(profile_name=profile)
|
||||||
|
.client("sts")
|
||||||
|
.get_caller_identity()["Account"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def schedule_when(detail: dict) -> datetime | None:
|
def schedule_when(detail: dict) -> datetime | None:
|
||||||
|
|
@ -123,13 +129,17 @@ def main() -> int:
|
||||||
if code == "ConflictException":
|
if code == "ConflictException":
|
||||||
print(f"exists {name}")
|
print(f"exists {name}")
|
||||||
elif code == "ValidationException":
|
elif code == "ValidationException":
|
||||||
print(f"skip invalid {name}: {exc.response['Error'].get('Message', code)}")
|
print(
|
||||||
|
f"skip invalid {name}: {exc.response['Error'].get('Message', code)}"
|
||||||
|
)
|
||||||
skipped += 1
|
skipped += 1
|
||||||
else:
|
else:
|
||||||
print(f"failed {name}: {code}", file=sys.stderr)
|
print(f"failed {name}: {code}", file=sys.stderr)
|
||||||
failed += 1
|
failed += 1
|
||||||
|
|
||||||
print(f"created={created} skipped_past={skipped} failed={failed} execute={args.execute}")
|
print(
|
||||||
|
f"created={created} skipped_past={skipped} failed={failed} execute={args.execute}"
|
||||||
|
)
|
||||||
if not args.execute:
|
if not args.execute:
|
||||||
print("dry-run; pass --execute to CreateSchedule")
|
print("dry-run; pass --execute to CreateSchedule")
|
||||||
return 1 if failed else 0
|
return 1 if failed else 0
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,9 @@ def _copy_tree(src: Path, dest: Path) -> None:
|
||||||
if item.is_dir():
|
if item.is_dir():
|
||||||
if item.name == "__pycache__":
|
if item.name == "__pycache__":
|
||||||
continue
|
continue
|
||||||
shutil.copytree(item, target, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
shutil.copytree(
|
||||||
|
item, target, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
shutil.copy2(item, target)
|
shutil.copy2(item, target)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,11 @@ from __future__ import annotations
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule
|
from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule
|
||||||
from shared.shift_clock import (
|
from shared.shift_clock import (
|
||||||
EASTERN,
|
EASTERN,
|
||||||
holiday_window_active,
|
holiday_window_active,
|
||||||
is_today,
|
|
||||||
shift_end,
|
shift_end,
|
||||||
shift_ended,
|
shift_ended,
|
||||||
shift_start,
|
shift_start,
|
||||||
|
|
@ -70,7 +68,9 @@ def _json_safe(value):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _slot_payload(schedule: ShiftSchedule, date_str: str, day_name: str, shift_type: str, my_ext: str) -> dict:
|
def _slot_payload(
|
||||||
|
schedule: ShiftSchedule, date_str: str, day_name: str, shift_type: str, my_ext: str
|
||||||
|
) -> dict:
|
||||||
ctx = schedule.get_shift_context(date_str, day_name, shift_type)
|
ctx = schedule.get_shift_context(date_str, day_name, shift_type)
|
||||||
assignees = [
|
assignees = [
|
||||||
{"extension": a["extension"], "name": a["name"]} for a in ctx["assignees"]
|
{"extension": a["extension"], "name": a["name"]} for a in ctx["assignees"]
|
||||||
|
|
@ -86,8 +86,13 @@ def _slot_payload(schedule: ShiftSchedule, date_str: str, day_name: str, shift_t
|
||||||
"multiplier": _json_safe(ctx.get("multiplier") or 1),
|
"multiplier": _json_safe(ctx.get("multiplier") or 1),
|
||||||
"assignees": assignees,
|
"assignees": assignees,
|
||||||
"mine": mine,
|
"mine": mine,
|
||||||
"canPick": (not mine) and open_slots > 0 and not shift_ended(date_str, shift_type),
|
"canPick": (not mine)
|
||||||
"canDrop": mine and not within_drop_lock(date_str, "day" if ctx["kind"] == "holiday" else shift_type),
|
and open_slots > 0
|
||||||
|
and not shift_ended(date_str, shift_type),
|
||||||
|
"canDrop": mine
|
||||||
|
and not within_drop_lock(
|
||||||
|
date_str, "day" if ctx["kind"] == "holiday" else shift_type
|
||||||
|
),
|
||||||
"latePickup": (not mine)
|
"latePickup": (not mine)
|
||||||
and open_slots > 0
|
and open_slots > 0
|
||||||
and shift_started(date_str, shift_type)
|
and shift_started(date_str, shift_type)
|
||||||
|
|
@ -192,7 +197,9 @@ def _pickup_sk_parts(sk: str) -> tuple[str, str, str]:
|
||||||
return date_str, shift_type, ext
|
return date_str, shift_type, ext
|
||||||
|
|
||||||
|
|
||||||
def pick(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str | None) -> dict:
|
def pick(
|
||||||
|
schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str | None
|
||||||
|
) -> dict:
|
||||||
date = _parse_date(date_str)
|
date = _parse_date(date_str)
|
||||||
date_str = date.strftime(DATE_FMT)
|
date_str = date.strftime(DATE_FMT)
|
||||||
if date_str < datetime.now(EASTERN).strftime(DATE_FMT):
|
if date_str < datetime.now(EASTERN).strftime(DATE_FMT):
|
||||||
|
|
@ -203,7 +210,9 @@ def pick(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str
|
||||||
token = effects.slack_token()
|
token = effects.slack_token()
|
||||||
if ctx["kind"] == "holiday":
|
if ctx["kind"] == "holiday":
|
||||||
return _pick_holiday(schedule, employee, date, date_str, ctx, token)
|
return _pick_holiday(schedule, employee, date, date_str, ctx, token)
|
||||||
return _pick_regular(schedule, employee, date, date_str, day_name, shift_type, ctx, token)
|
return _pick_regular(
|
||||||
|
schedule, employee, date, date_str, day_name, shift_type, ctx, token
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_pick_type(schedule, date_str, day_name, explicit) -> str:
|
def _resolve_pick_type(schedule, date_str, day_name, explicit) -> str:
|
||||||
|
|
@ -219,7 +228,9 @@ def _resolve_pick_type(schedule, date_str, day_name, explicit) -> str:
|
||||||
return "night"
|
return "night"
|
||||||
|
|
||||||
|
|
||||||
def _pick_regular(schedule, employee, date, date_str, day_name, shift_type, ctx, token) -> dict:
|
def _pick_regular(
|
||||||
|
schedule, employee, date, date_str, day_name, shift_type, ctx, token
|
||||||
|
) -> dict:
|
||||||
assignees = ctx["assignees"]
|
assignees = ctx["assignees"]
|
||||||
if assignees and assignees[0]["extension"] != employee["extension"]:
|
if assignees and assignees[0]["extension"] != employee["extension"]:
|
||||||
raise ActionError(
|
raise ActionError(
|
||||||
|
|
@ -230,14 +241,20 @@ def _pick_regular(schedule, employee, date, date_str, day_name, shift_type, ctx,
|
||||||
if shift_ended(date_str, shift_type):
|
if shift_ended(date_str, shift_type):
|
||||||
raise ActionError(400, "ENDED", "That shift has already ended.")
|
raise ActionError(400, "ENDED", "That shift has already ended.")
|
||||||
if shift_started(date_str, shift_type):
|
if shift_started(date_str, shift_type):
|
||||||
return _request_late_pickup(schedule, employee, date_str, shift_type, False, token)
|
return _request_late_pickup(
|
||||||
already_mine = bool(assignees) and assignees[0]["extension"] == employee["extension"]
|
schedule, employee, date_str, shift_type, False, token
|
||||||
|
)
|
||||||
|
already_mine = (
|
||||||
|
bool(assignees) and assignees[0]["extension"] == employee["extension"]
|
||||||
|
)
|
||||||
if not already_mine:
|
if not already_mine:
|
||||||
claimed = schedule.claim_open_shift(
|
claimed = schedule.claim_open_shift(
|
||||||
date_str, employee["extension"], employee["name"], shift_type
|
date_str, employee["extension"], employee["name"], shift_type
|
||||||
)
|
)
|
||||||
if not claimed:
|
if not claimed:
|
||||||
raise ActionError(409, "TAKEN", "That shift was just picked up by someone else.")
|
raise ActionError(
|
||||||
|
409, "TAKEN", "That shift was just picked up by someone else."
|
||||||
|
)
|
||||||
effects.maybe_repoint_today(date_str, shift_type, employee["extension"])
|
effects.maybe_repoint_today(date_str, shift_type, employee["extension"])
|
||||||
effects.post_shift_change(
|
effects.post_shift_change(
|
||||||
token,
|
token,
|
||||||
|
|
@ -278,7 +295,9 @@ def _pick_holiday(schedule, employee, date, date_str, ctx, token) -> dict:
|
||||||
return {"ok": True, "latePickup": False, "message": "Holiday slot claimed."}
|
return {"ok": True, "latePickup": False, "message": "Holiday slot claimed."}
|
||||||
|
|
||||||
|
|
||||||
def _request_late_pickup(schedule, employee, date_str, shift_type, is_holiday, token) -> dict:
|
def _request_late_pickup(
|
||||||
|
schedule, employee, date_str, shift_type, is_holiday, token
|
||||||
|
) -> dict:
|
||||||
schedule.create_pickup_request(
|
schedule.create_pickup_request(
|
||||||
date_str,
|
date_str,
|
||||||
shift_type,
|
shift_type,
|
||||||
|
|
@ -303,7 +322,9 @@ def _request_late_pickup(schedule, employee, date_str, shift_type, is_holiday, t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def drop(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str | None) -> dict:
|
def drop(
|
||||||
|
schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str | None
|
||||||
|
) -> dict:
|
||||||
date = _parse_date(date_str)
|
date = _parse_date(date_str)
|
||||||
date_str = date.strftime(DATE_FMT)
|
date_str = date.strftime(DATE_FMT)
|
||||||
if date_str < datetime.now(EASTERN).strftime(DATE_FMT):
|
if date_str < datetime.now(EASTERN).strftime(DATE_FMT):
|
||||||
|
|
@ -317,7 +338,9 @@ def drop(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str
|
||||||
elif target in ("day", "night"):
|
elif target in ("day", "night"):
|
||||||
target = target
|
target = target
|
||||||
else:
|
else:
|
||||||
raise ActionError(400, "INVALID_SHIFT", "Shift type must be day, night, or holiday.")
|
raise ActionError(
|
||||||
|
400, "INVALID_SHIFT", "Shift type must be day, night, or holiday."
|
||||||
|
)
|
||||||
if target == "day" and "holiday" in held:
|
if target == "day" and "holiday" in held:
|
||||||
target = "holiday"
|
target = "holiday"
|
||||||
if target not in held:
|
if target not in held:
|
||||||
|
|
@ -450,7 +473,11 @@ def swap(
|
||||||
|
|
||||||
|
|
||||||
def respond_swap(
|
def respond_swap(
|
||||||
schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str, accept: bool
|
schedule: ShiftSchedule,
|
||||||
|
employee: dict,
|
||||||
|
date_str: str,
|
||||||
|
shift_type: str,
|
||||||
|
accept: bool,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
date = _parse_date(date_str)
|
date = _parse_date(date_str)
|
||||||
date_str = date.strftime(DATE_FMT)
|
date_str = date.strftime(DATE_FMT)
|
||||||
|
|
@ -474,11 +501,16 @@ def respond_swap(
|
||||||
return {"ok": True, "message": "Swap declined."}
|
return {"ok": True, "message": "Swap declined."}
|
||||||
if shift_started(date_str, shift_type):
|
if shift_started(date_str, shift_type):
|
||||||
schedule.clear_swap(date_str, shift_type)
|
schedule.clear_swap(date_str, shift_type)
|
||||||
raise ActionError(409, "EXPIRED", "This swap expired because the shift has started.")
|
raise ActionError(
|
||||||
|
409, "EXPIRED", "This swap expired because the shift has started."
|
||||||
|
)
|
||||||
is_holiday = shift_type == "day" and schedule.get_holiday(date_str) is not None
|
is_holiday = shift_type == "day" and schedule.get_holiday(date_str) is not None
|
||||||
if is_holiday:
|
if is_holiday:
|
||||||
moved = schedule.swap_holiday_assignee(
|
moved = schedule.swap_holiday_assignee(
|
||||||
date_str, swap_row["requester_ext"], swap_row["target_ext"], employee["name"]
|
date_str,
|
||||||
|
swap_row["requester_ext"],
|
||||||
|
swap_row["target_ext"],
|
||||||
|
employee["name"],
|
||||||
)
|
)
|
||||||
if not moved:
|
if not moved:
|
||||||
schedule.clear_swap(date_str, shift_type)
|
schedule.clear_swap(date_str, shift_type)
|
||||||
|
|
@ -583,11 +615,15 @@ def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) ->
|
||||||
date = _parse_date(date_str)
|
date = _parse_date(date_str)
|
||||||
date_str = date.strftime(DATE_FMT)
|
date_str = date.strftime(DATE_FMT)
|
||||||
if date_str < datetime.now(EASTERN).strftime(DATE_FMT):
|
if date_str < datetime.now(EASTERN).strftime(DATE_FMT):
|
||||||
raise ActionError(400, "PAST_SHIFT", "You can't schedule a holiday in the past.")
|
raise ActionError(
|
||||||
|
400, "PAST_SHIFT", "You can't schedule a holiday in the past."
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
slot_count = int(slots)
|
slot_count = int(slots)
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
raise ActionError(400, "INVALID_SLOTS", "Slots must be a whole number.") from exc
|
raise ActionError(
|
||||||
|
400, "INVALID_SLOTS", "Slots must be a whole number."
|
||||||
|
) from exc
|
||||||
if slot_count < 1:
|
if slot_count < 1:
|
||||||
raise ActionError(400, "INVALID_SLOTS", "Slots must be at least 1.")
|
raise ActionError(400, "INVALID_SLOTS", "Slots must be at least 1.")
|
||||||
name = (label or "").strip()
|
name = (label or "").strip()
|
||||||
|
|
@ -620,7 +656,11 @@ def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) ->
|
||||||
effects.activate_holiday_inline(schedule, date_str)
|
effects.activate_holiday_inline(schedule, date_str)
|
||||||
holiday = schedule.get_holiday(date_str)
|
holiday = schedule.get_holiday(date_str)
|
||||||
effects.post_holiday_added(
|
effects.post_holiday_added(
|
||||||
token, date_str, name, slot_count, holiday["multiplier"] if holiday else multiplier
|
token,
|
||||||
|
date_str,
|
||||||
|
name,
|
||||||
|
slot_count,
|
||||||
|
holiday["multiplier"] if holiday else multiplier,
|
||||||
)
|
)
|
||||||
effects.refresh_schedule_post(schedule, token)
|
effects.refresh_schedule_post(schedule, token)
|
||||||
return {"ok": True, "message": f"Scheduled {name}."}
|
return {"ok": True, "message": f"Scheduled {name}."}
|
||||||
|
|
@ -639,7 +679,9 @@ def admin_holiday_remove(schedule, employee, date_str) -> dict:
|
||||||
return {"ok": True, "message": "Holiday removed."}
|
return {"ok": True, "message": "Holiday removed."}
|
||||||
|
|
||||||
|
|
||||||
def admin_pickup(schedule, employee, date_str, shift_type, extension, approve: bool) -> dict:
|
def admin_pickup(
|
||||||
|
schedule, employee, date_str, shift_type, extension, approve: bool
|
||||||
|
) -> dict:
|
||||||
require_admin(schedule, employee)
|
require_admin(schedule, employee)
|
||||||
date = _parse_date(date_str)
|
date = _parse_date(date_str)
|
||||||
date_str = date.strftime(DATE_FMT)
|
date_str = date.strftime(DATE_FMT)
|
||||||
|
|
|
||||||
|
|
@ -293,7 +293,9 @@ class ShiftSchedule:
|
||||||
|
|
||||||
def list_pending_swaps(self) -> list[dict]:
|
def list_pending_swaps(self) -> list[dict]:
|
||||||
resp = self.table.query(KeyConditionExpression=Key("PK").eq("SWAP"))
|
resp = self.table.query(KeyConditionExpression=Key("PK").eq("SWAP"))
|
||||||
return [item for item in resp.get("Items", []) if item.get("status") == "pending"]
|
return [
|
||||||
|
item for item in resp.get("Items", []) if item.get("status") == "pending"
|
||||||
|
]
|
||||||
|
|
||||||
# ── Late-pickup requests ─────────────────────────────────────────────
|
# ── Late-pickup requests ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -381,7 +383,9 @@ class ShiftSchedule:
|
||||||
|
|
||||||
def list_pending_pickup_requests(self) -> list[dict]:
|
def list_pending_pickup_requests(self) -> list[dict]:
|
||||||
resp = self.table.query(KeyConditionExpression=Key("PK").eq("PICKUP_REQUEST"))
|
resp = self.table.query(KeyConditionExpression=Key("PK").eq("PICKUP_REQUEST"))
|
||||||
return [item for item in resp.get("Items", []) if item.get("status") == "pending"]
|
return [
|
||||||
|
item for item in resp.get("Items", []) if item.get("status") == "pending"
|
||||||
|
]
|
||||||
|
|
||||||
# ── Holidays ────────────────────────────────────────────────────────
|
# ── Holidays ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
# GitHub Actions OIDC role for .github/workflows/deploy-api.yaml.
|
# GitHub Actions OIDC role for the thin deploy-api.yaml caller of
|
||||||
|
# org reusable cd-hcp-fargate.yaml.
|
||||||
#
|
#
|
||||||
# One role: GitHub Environments have a single DEPLOY_ROLE_ARN. Trust is pinned
|
# One role: GitHub Environments have a single DEPLOY_ROLE_ARN. Trust is pinned
|
||||||
# to Environments dev and prod (immutable and classic subject forms) and
|
# to Environments dev and prod. job_workflow_ref matches the reusable at any ref.
|
||||||
# job_workflow_ref to deploy-api.yaml at main and v*.
|
# AWS STS GitHub condition keys include job_workflow_ref, not workflow_ref.
|
||||||
|
|
||||||
data "aws_iam_policy_document" "github_deploy_assume" {
|
data "aws_iam_policy_document" "github_deploy_assume" {
|
||||||
statement {
|
statement {
|
||||||
|
|
@ -31,8 +32,7 @@ data "aws_iam_policy_document" "github_deploy_assume" {
|
||||||
test = "StringLike"
|
test = "StringLike"
|
||||||
variable = "token.actions.githubusercontent.com:job_workflow_ref"
|
variable = "token.actions.githubusercontent.com:job_workflow_ref"
|
||||||
values = [
|
values = [
|
||||||
"${var.github_repo}/.github/workflows/deploy-api.yaml@refs/heads/${var.github_deploy_branch}",
|
"Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*",
|
||||||
"${var.github_repo}/.github/workflows/deploy-api.yaml@refs/tags/v*",
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,12 +76,6 @@ variable "github_repo" {
|
||||||
default = "Sea-Haven-Industries/afterhours-shift-manager"
|
default = "Sea-Haven-Industries/afterhours-shift-manager"
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "github_deploy_branch" {
|
|
||||||
description = "Git branch pinned in job_workflow_ref for the deploy role."
|
|
||||||
type = string
|
|
||||||
default = "main"
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "checkcomponents_queue_url" {
|
variable "checkcomponents_queue_url" {
|
||||||
description = "paychex-checkcomponents SQS URL. Empty skips the weekly SendMessage."
|
description = "paychex-checkcomponents SQS URL. Empty skips the weekly SendMessage."
|
||||||
type = string
|
type = string
|
||||||
|
|
|
||||||
|
|
@ -77,13 +77,13 @@ def test_stack_owns_a_vpc_instead_of_looking_up_default():
|
||||||
assert "aws_vpc.this.id" in ecs
|
assert "aws_vpc.this.id" in ecs
|
||||||
assert "aws_subnet.public[*].id" in ecs
|
assert "aws_subnet.public[*].id" in ecs
|
||||||
assert "ec2:CreateVpc" in HCP_IAM
|
assert "ec2:CreateVpc" in HCP_IAM
|
||||||
assert "sid = \"RefreshVpc\"" in HCP_IAM
|
assert 'sid = "RefreshVpc"' in HCP_IAM
|
||||||
assert "afterhours-shift-manager-ecs" in HCP_IAM
|
assert "afterhours-shift-manager-ecs" in HCP_IAM
|
||||||
assert "hcptf_apply_ecs" in HCP_IAM
|
assert "hcptf_apply_ecs" in HCP_IAM
|
||||||
assert 'resource "aws_iam_policy" "hcptf_apply_ecs"' in HCP_IAM
|
assert 'resource "aws_iam_policy" "hcptf_apply_ecs"' in HCP_IAM
|
||||||
assert 'resource "aws_iam_role_policy" "hcptf_apply_ecs"' not in HCP_IAM
|
assert 'resource "aws_iam_role_policy" "hcptf_apply_ecs"' not in HCP_IAM
|
||||||
assert "aws_iam_policy.hcptf_apply_ecs.arn" in HCP_IAM
|
assert "aws_iam_policy.hcptf_apply_ecs.arn" in HCP_IAM
|
||||||
assert "sid = \"CreateElbAndEcsServiceLinkedRoles\"" in HCP_IAM
|
assert 'sid = "CreateElbAndEcsServiceLinkedRoles"' in HCP_IAM
|
||||||
assert "iam:CreateServiceLinkedRole" in HCP_IAM
|
assert "iam:CreateServiceLinkedRole" in HCP_IAM
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -101,10 +101,13 @@ def test_ecs_task_boundary_uses_static_arns():
|
||||||
|
|
||||||
def test_deploy_api_workflow_exists():
|
def test_deploy_api_workflow_exists():
|
||||||
deploy_api = (ROOT / ".github" / "workflows" / "deploy-api.yaml").read_text()
|
deploy_api = (ROOT / ".github" / "workflows" / "deploy-api.yaml").read_text()
|
||||||
assert "environment: ${{ needs.target.outputs.environment }}" in deploy_api
|
pin = "cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16"
|
||||||
assert "/afterhours-shift-manager/deploy/cluster" in deploy_api
|
assert deploy_api.count(pin) == 2
|
||||||
assert "linux/arm64" in deploy_api
|
assert "ssm-prefix: /afterhours-shift-manager/deploy" in deploy_api
|
||||||
|
assert "docker-platform: linux/arm64" in deploy_api
|
||||||
|
assert "ship-gate: true" in deploy_api
|
||||||
assert "gh release create" in deploy_api
|
assert "gh release create" in deploy_api
|
||||||
|
assert "needs.target.outputs.environment" not in deploy_api
|
||||||
|
|
||||||
|
|
||||||
def test_in_repo_hcptf_roles():
|
def test_in_repo_hcptf_roles():
|
||||||
|
|
@ -116,10 +119,12 @@ def test_in_repo_hcptf_roles():
|
||||||
|
|
||||||
def test_ci_runs_pytest_and_terraform_validate():
|
def test_ci_runs_pytest_and_terraform_validate():
|
||||||
assert "ci-python-sam" not in CI
|
assert "ci-python-sam" not in CI
|
||||||
|
assert "ci-python-app.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI
|
||||||
|
assert "ci-terraform.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI
|
||||||
|
assert "ci-autofix.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI
|
||||||
|
assert "name: ci-complete" in CI
|
||||||
assert "pytest" in CI
|
assert "pytest" in CI
|
||||||
assert "terraform fmt -check" in CI
|
assert "terraform fmt -check" not in CI
|
||||||
assert "terraform init -backend=false" in CI
|
|
||||||
assert "terraform validate" in CI
|
|
||||||
assert "openapi:lint" in CI
|
assert "openapi:lint" in CI
|
||||||
assert "npm ci" in CI
|
assert "npm ci" in CI
|
||||||
|
|
||||||
|
|
@ -138,7 +143,7 @@ def test_openapi_uses_redocly_recommended():
|
||||||
health = spec.split("/api/health:", 1)[1].split("\n /", 1)[0]
|
health = spec.split("/api/health:", 1)[1].split("\n /", 1)[0]
|
||||||
assert '"403":' in health
|
assert '"403":' in health
|
||||||
assert '"400":' not in health
|
assert '"400":' not in health
|
||||||
assert 'root: openapi.yaml' in redocly
|
assert "root: openapi.yaml" in redocly
|
||||||
assert '"openapi:lint"' in package
|
assert '"openapi:lint"' in package
|
||||||
assert '"@redocly/cli": "2.52.1"' in package
|
assert '"@redocly/cli": "2.52.1"' in package
|
||||||
assert "openapi: 3.1.0" in spec
|
assert "openapi: 3.1.0" in spec
|
||||||
|
|
@ -191,8 +196,8 @@ def test_github_deploy_trust_covers_image_only():
|
||||||
assert "environment:prod" in iam or "environment:prod" in LOCALS
|
assert "environment:prod" in iam or "environment:prod" in LOCALS
|
||||||
assert "environment:dev" in iam or "environment:dev" in LOCALS
|
assert "environment:dev" in iam or "environment:dev" in LOCALS
|
||||||
assert "deploy.yaml@" not in iam
|
assert "deploy.yaml@" not in iam
|
||||||
assert "deploy-api.yaml@refs/heads/${var.github_deploy_branch}" in iam
|
assert "deploy-api.yaml@" not in iam
|
||||||
assert "deploy-api.yaml@refs/tags/v*" in iam
|
assert "Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*" in iam
|
||||||
assert "ecs:ListTasks" in iam
|
assert "ecs:ListTasks" in iam
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -205,10 +210,10 @@ def test_plan_refresh_includes_provider6_s3_gets():
|
||||||
def test_origins_use_fargate_url():
|
def test_origins_use_fargate_url():
|
||||||
outputs = (TERRAFORM / "outputs.tf").read_text()
|
outputs = (TERRAFORM / "outputs.tf").read_text()
|
||||||
assert "aws_apigatewayv2_api.http" not in outputs
|
assert "aws_apigatewayv2_api.http" not in outputs
|
||||||
assert '${local.api_url}/slack/events' in outputs
|
assert "${local.api_url}/slack/events" in outputs
|
||||||
assert "value = local.api_url" in outputs
|
assert "value = local.api_url" in outputs
|
||||||
assert "output \"vpc_id\"" in outputs
|
assert 'output "vpc_id"' in outputs
|
||||||
assert "output \"public_subnet_ids\"" in outputs
|
assert 'output "public_subnet_ids"' in outputs
|
||||||
assert "aws_vpc.this.id" in outputs
|
assert "aws_vpc.this.id" in outputs
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,7 @@ def test_adds_removes_and_preserves_links(
|
||||||
def test_renames_changed_member_preserving_link(
|
def test_renames_changed_member_preserving_link(
|
||||||
rostersync_app, schedule, seed, env, fake_3cx
|
rostersync_app, schedule, seed, env, fake_3cx
|
||||||
):
|
):
|
||||||
seed.roster(
|
seed.roster("114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com")
|
||||||
"114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com"
|
|
||||||
)
|
|
||||||
fake_3cx([{"Number": "114", "MemberName": "Alicia", "Type": "Extension"}])
|
fake_3cx([{"Number": "114", "MemberName": "Alicia", "Type": "Extension"}])
|
||||||
|
|
||||||
result = rostersync_app.handler({"force": True}, None)
|
result = rostersync_app.handler({"force": True}, None)
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,9 @@ def test_execute_puts_into_empty_terraform_shells():
|
||||||
rc = mod.copy_secrets(src, dst, execute=True)
|
rc = mod.copy_secrets(src, dst, execute=True)
|
||||||
assert rc == 0
|
assert rc == 0
|
||||||
assert [name for name, _ in dst.puts] == list(mod.COPY)
|
assert [name for name, _ in dst.puts] == list(mod.COPY)
|
||||||
assert all(value.endswith("-value") and not value.endswith("\n") for _, value in dst.puts)
|
assert all(
|
||||||
|
value.endswith("-value") and not value.endswith("\n") for _, value in dst.puts
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_skip_populated_copy_targets_and_never_write_3cx():
|
def test_skip_populated_copy_targets_and_never_write_3cx():
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,9 @@ def test_schedule_when_parses_at_expression_in_eastern():
|
||||||
"ScheduleExpressionTimezone": "America/New_York",
|
"ScheduleExpressionTimezone": "America/New_York",
|
||||||
}
|
}
|
||||||
when = mod.schedule_when(detail)
|
when = mod.schedule_when(detail)
|
||||||
expected = datetime(2026, 7, 4, 8, 0, 0, tzinfo=ZoneInfo("America/New_York")).astimezone(
|
expected = datetime(
|
||||||
timezone.utc
|
2026, 7, 4, 8, 0, 0, tzinfo=ZoneInfo("America/New_York")
|
||||||
)
|
).astimezone(timezone.utc)
|
||||||
assert when == expected
|
assert when == expected
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,7 @@ def quiet_slack(monkeypatch):
|
||||||
|
|
||||||
|
|
||||||
def _alice(schedule, seed):
|
def _alice(schedule, seed):
|
||||||
seed.roster(
|
seed.roster("114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com")
|
||||||
"114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com"
|
|
||||||
)
|
|
||||||
seed.roster("115", "Bob", slack_user_id="U_BOB", email="bob@seahavenind.com")
|
seed.roster("115", "Bob", slack_user_id="U_BOB", email="bob@seahavenind.com")
|
||||||
return schedule.get_employee_by_extension("114")
|
return schedule.get_employee_by_extension("114")
|
||||||
|
|
||||||
|
|
@ -143,10 +141,9 @@ def test_admin_open_repoints_active_shift_to_fallback(
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
effects,
|
effects,
|
||||||
"maybe_repoint_today",
|
"maybe_repoint_today",
|
||||||
lambda date, shift_type, extension: calls.append(
|
lambda date, shift_type, extension: (
|
||||||
(date, shift_type, extension)
|
calls.append((date, shift_type, extension)) or True
|
||||||
)
|
),
|
||||||
or True,
|
|
||||||
)
|
)
|
||||||
with freezegun.freeze_time("2026-06-10 20:00:00-04:00"):
|
with freezegun.freeze_time("2026-06-10 20:00:00-04:00"):
|
||||||
result = admin_open(schedule, employee, "2026-06-10", "night")
|
result = admin_open(schedule, employee, "2026-06-10", "night")
|
||||||
|
|
@ -164,10 +161,9 @@ def test_admin_clear_repoints_active_shift_to_resolved_holder(
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
effects,
|
effects,
|
||||||
"maybe_repoint_today",
|
"maybe_repoint_today",
|
||||||
lambda date, shift_type, extension: calls.append(
|
lambda date, shift_type, extension: (
|
||||||
(date, shift_type, extension)
|
calls.append((date, shift_type, extension)) or True
|
||||||
)
|
),
|
||||||
or True,
|
|
||||||
)
|
)
|
||||||
with freezegun.freeze_time("2026-06-10 20:00:00-04:00"):
|
with freezegun.freeze_time("2026-06-10 20:00:00-04:00"):
|
||||||
result = admin_clear(schedule, employee, "2026-06-10", "night")
|
result = admin_clear(schedule, employee, "2026-06-10", "night")
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,9 @@ class TestRoster:
|
||||||
assert emp["email"] == "Alice@Seahavenind.com"
|
assert emp["email"] == "Alice@Seahavenind.com"
|
||||||
|
|
||||||
def test_upsert_without_email_preserves_existing_email(self, schedule, seed):
|
def test_upsert_without_email_preserves_existing_email(self, schedule, seed):
|
||||||
seed.roster("114", "Alice", slack_user_id="U_OLD", email="alice@seahavenind.com")
|
seed.roster(
|
||||||
|
"114", "Alice", slack_user_id="U_OLD", email="alice@seahavenind.com"
|
||||||
|
)
|
||||||
schedule.upsert_roster_entry("114", "Alicia", "U_ALICE")
|
schedule.upsert_roster_entry("114", "Alicia", "U_ALICE")
|
||||||
emp = schedule.get_employee_by_extension("114")
|
emp = schedule.get_employee_by_extension("114")
|
||||||
assert emp["email"] == "alice@seahavenind.com"
|
assert emp["email"] == "alice@seahavenind.com"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue