Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43ee1c3531 | |||
| f03d7a33c8 | |||
| 7bf0165fe4 | |||
| ef63c47b7c | |||
| be75c7f254 | |||
| e90637c227 | |||
| 67e60c9ae1 | |||
| a25b673dd6 | |||
| 4e003ba3d0 | |||
| 4996ff7432 | |||
| ffc6c7960d | |||
| cf16415720 | |||
| 33f9e17339 | |||
| 7639be9a41 | |||
| ebe439ce84 | |||
| a663729121 | |||
| 4fc7933e30 | |||
| 6e0cb93ee2 | |||
| 0e4848f8b4 | |||
| bb7010f881 | |||
| 4756e1c1c5 | |||
| 73c038e406 | |||
| 02e34d65bb | |||
| a869bb42d7 | |||
| d77d1b58b8 | |||
| d86c0001eb | |||
| 5cc2bb78e9 | |||
| c9075be6e0 | |||
| 6c297b5e81 | |||
| 80004e4285 | |||
| 94f99595fc |
+167
-28
@@ -2,9 +2,9 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, dev, uat]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main, dev, uat]
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.ref }}
|
group: ci-${{ github.ref }}
|
||||||
@@ -99,10 +99,11 @@ jobs:
|
|||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: runners-cartsnitch
|
runs-on: runners-cartsnitch
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push'
|
||||||
needs: [lint, test, e2e]
|
needs: [lint, test, e2e]
|
||||||
outputs:
|
outputs:
|
||||||
calver_tag: ${{ steps.calver.outputs.version }}
|
calver_tag: ${{ steps.calver.outputs.version }}
|
||||||
|
sha_tag: sha-${{ github.sha }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -126,14 +127,14 @@ jobs:
|
|||||||
echo "CalVer tag: $VERSION"
|
echo "CalVer tag: $VERSION"
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
@@ -146,7 +147,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=sha,prefix=sha-
|
type=sha,prefix=sha-,format=long
|
||||||
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
@@ -154,7 +155,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
push: ${{ github.event_name == 'push' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
target: prod
|
target: prod
|
||||||
@@ -169,10 +170,11 @@ jobs:
|
|||||||
|
|
||||||
build-and-push-auth:
|
build-and-push-auth:
|
||||||
runs-on: runners-cartsnitch
|
runs-on: runners-cartsnitch
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push'
|
||||||
needs: [lint, test, e2e]
|
needs: [lint, test, e2e]
|
||||||
outputs:
|
outputs:
|
||||||
calver_tag: ${{ steps.calver.outputs.version }}
|
calver_tag: ${{ steps.calver.outputs.version }}
|
||||||
|
sha_tag: sha-${{ github.sha }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -195,14 +197,14 @@ jobs:
|
|||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
@@ -215,7 +217,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.AUTH_IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.AUTH_IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=sha,prefix=sha-
|
type=sha,prefix=sha-,format=long
|
||||||
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
@@ -224,16 +226,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: ./auth
|
context: ./auth
|
||||||
file: ./auth/Dockerfile
|
file: ./auth/Dockerfile
|
||||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
push: ${{ github.event_name == 'push' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
build-and-push-receiptwitness:
|
build-and-push-receiptwitness:
|
||||||
runs-on: runners-cartsnitch
|
runs-on: runners-cartsnitch
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push'
|
||||||
needs: [lint, test]
|
needs: [lint, test]
|
||||||
outputs:
|
outputs:
|
||||||
calver_tag: ${{ steps.calver.outputs.version }}
|
calver_tag: ${{ steps.calver.outputs.version }}
|
||||||
|
sha_tag: sha-${{ github.sha }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -251,14 +254,14 @@ jobs:
|
|||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
@@ -271,7 +274,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.RECEIPTWITNESS_IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.RECEIPTWITNESS_IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=sha,prefix=sha-
|
type=sha,prefix=sha-,format=long
|
||||||
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
@@ -280,16 +283,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./receiptwitness/Dockerfile
|
file: ./receiptwitness/Dockerfile
|
||||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
push: ${{ github.event_name == 'push' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
build-and-push-api:
|
build-and-push-api:
|
||||||
runs-on: runners-cartsnitch
|
runs-on: runners-cartsnitch
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push'
|
||||||
needs: [lint, test]
|
needs: [lint, test]
|
||||||
outputs:
|
outputs:
|
||||||
calver_tag: ${{ steps.calver.outputs.version }}
|
calver_tag: ${{ steps.calver.outputs.version }}
|
||||||
|
sha_tag: sha-${{ github.sha }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -307,14 +311,14 @@ jobs:
|
|||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
@@ -327,23 +331,23 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.API_IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.API_IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=sha,prefix=sha-
|
type=sha,prefix=sha-,format=long
|
||||||
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
- name: Build and push API Docker image
|
- name: Build and push API Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: ./api
|
||||||
file: ./api/Dockerfile
|
file: ./api/Dockerfile
|
||||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
push: ${{ github.event_name == 'push' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
deploy-dev:
|
deploy-dev:
|
||||||
runs-on: runners-cartsnitch
|
runs-on: runners-cartsnitch
|
||||||
needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api]
|
needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api]
|
||||||
if: always() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
|
||||||
steps:
|
steps:
|
||||||
- name: Generate GitHub App token
|
- name: Generate GitHub App token
|
||||||
id: app-token
|
id: app-token
|
||||||
@@ -368,29 +372,65 @@ jobs:
|
|||||||
- name: Install kustomize
|
- name: Install kustomize
|
||||||
uses: imranismail/setup-kustomize@v2
|
uses: imranismail/setup-kustomize@v2
|
||||||
|
|
||||||
|
- name: Determine image tag for frontend
|
||||||
|
id: frontend_tag
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||||
|
echo "tag=${{ needs.build-and-push.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tag=${{ needs.build-and-push.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Update frontend image tag
|
- name: Update frontend image tag
|
||||||
if: needs.build-and-push.result == 'success'
|
if: needs.build-and-push.result == 'success'
|
||||||
run: |
|
run: |
|
||||||
cd infra/apps/overlays/dev
|
cd infra/apps/overlays/dev
|
||||||
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }}
|
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ steps.frontend_tag.outputs.tag }}
|
||||||
|
|
||||||
|
- name: Determine image tag for auth
|
||||||
|
id: auth_tag
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||||
|
echo "tag=${{ needs.build-and-push-auth.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tag=${{ needs.build-and-push-auth.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Update auth image tag
|
- name: Update auth image tag
|
||||||
if: needs.build-and-push-auth.result == 'success'
|
if: needs.build-and-push-auth.result == 'success'
|
||||||
run: |
|
run: |
|
||||||
cd infra/apps/overlays/dev
|
cd infra/apps/overlays/dev
|
||||||
kustomize edit set image ghcr.io/cartsnitch/auth:${{ needs.build-and-push-auth.outputs.calver_tag }}
|
kustomize edit set image ghcr.io/cartsnitch/auth:${{ steps.auth_tag.outputs.tag }}
|
||||||
|
|
||||||
|
- name: Determine image tag for receiptwitness
|
||||||
|
id: receiptwitness_tag
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||||
|
echo "tag=${{ needs.build-and-push-receiptwitness.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tag=${{ needs.build-and-push-receiptwitness.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Update receiptwitness image tag
|
- name: Update receiptwitness image tag
|
||||||
if: needs.build-and-push-receiptwitness.result == 'success'
|
if: needs.build-and-push-receiptwitness.result == 'success'
|
||||||
run: |
|
run: |
|
||||||
cd infra/apps/overlays/dev
|
cd infra/apps/overlays/dev
|
||||||
kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ needs.build-and-push-receiptwitness.outputs.calver_tag }}
|
kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ steps.receiptwitness_tag.outputs.tag }}
|
||||||
|
|
||||||
|
- name: Determine image tag for api
|
||||||
|
id: api_tag
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||||
|
echo "tag=${{ needs.build-and-push-api.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tag=${{ needs.build-and-push-api.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Update api image tag
|
- name: Update api image tag
|
||||||
if: needs.build-and-push-api.result == 'success'
|
if: needs.build-and-push-api.result == 'success'
|
||||||
run: |
|
run: |
|
||||||
cd infra/apps/overlays/dev
|
cd infra/apps/overlays/dev
|
||||||
kustomize edit set image ghcr.io/cartsnitch/api:${{ needs.build-and-push-api.outputs.calver_tag }}
|
kustomize edit set image ghcr.io/cartsnitch/api:${{ steps.api_tag.outputs.tag }}
|
||||||
|
|
||||||
- name: Commit and push to infra
|
- name: Commit and push to infra
|
||||||
run: |
|
run: |
|
||||||
@@ -399,4 +439,103 @@ jobs:
|
|||||||
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
|
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
|
||||||
git add apps/overlays/dev/kustomization.yaml
|
git add apps/overlays/dev/kustomization.yaml
|
||||||
git commit -m "ci(dev): update cartsnitch, auth, receiptwitness, and api images"
|
git commit -m "ci(dev): update cartsnitch, auth, receiptwitness, and api images"
|
||||||
|
git pull --rebase origin main
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
deploy-uat:
|
||||||
|
runs-on: runners-cartsnitch
|
||||||
|
needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api]
|
||||||
|
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/uat' || github.ref == 'refs/heads/main')
|
||||||
|
steps:
|
||||||
|
- name: Generate GitHub App token
|
||||||
|
id: app-token
|
||||||
|
uses: actions/create-github-app-token@v1
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.CARTSNITCH_APP_ID }}
|
||||||
|
private-key: ${{ secrets.CARTSNITCH_APP_PRIVATE_KEY }}
|
||||||
|
owner: ${{ github.repository_owner }}
|
||||||
|
repositories: infra
|
||||||
|
|
||||||
|
- name: Checkout infra repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: cartsnitch/infra
|
||||||
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
ref: main
|
||||||
|
path: infra
|
||||||
|
|
||||||
|
- name: Install kubectl
|
||||||
|
uses: azure/setup-kubectl@v4
|
||||||
|
|
||||||
|
- name: Install kustomize
|
||||||
|
uses: imranismail/setup-kustomize@v2
|
||||||
|
|
||||||
|
- name: Determine image tag for frontend
|
||||||
|
id: frontend_tag
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||||
|
echo "tag=${{ needs.build-and-push.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tag=${{ needs.build-and-push.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Update frontend image tag
|
||||||
|
if: needs.build-and-push.result == 'success'
|
||||||
|
run: |
|
||||||
|
cd infra/apps/overlays/uat
|
||||||
|
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ steps.frontend_tag.outputs.tag }}
|
||||||
|
|
||||||
|
- name: Determine image tag for auth
|
||||||
|
id: auth_tag
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||||
|
echo "tag=${{ needs.build-and-push-auth.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tag=${{ needs.build-and-push-auth.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Update auth image tag
|
||||||
|
if: needs.build-and-push-auth.result == 'success'
|
||||||
|
run: |
|
||||||
|
cd infra/apps/overlays/uat
|
||||||
|
kustomize edit set image ghcr.io/cartsnitch/auth:${{ steps.auth_tag.outputs.tag }}
|
||||||
|
|
||||||
|
- name: Determine image tag for receiptwitness
|
||||||
|
id: receiptwitness_tag
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||||
|
echo "tag=${{ needs.build-and-push-receiptwitness.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tag=${{ needs.build-and-push-receiptwitness.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Update receiptwitness image tag
|
||||||
|
if: needs.build-and-push-receiptwitness.result == 'success'
|
||||||
|
run: |
|
||||||
|
cd infra/apps/overlays/uat
|
||||||
|
kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ steps.receiptwitness_tag.outputs.tag }}
|
||||||
|
|
||||||
|
- name: Determine image tag for api
|
||||||
|
id: api_tag
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||||
|
echo "tag=${{ needs.build-and-push-api.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tag=${{ needs.build-and-push-api.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Update api image tag
|
||||||
|
if: needs.build-and-push-api.result == 'success'
|
||||||
|
run: |
|
||||||
|
cd infra/apps/overlays/uat
|
||||||
|
kustomize edit set image ghcr.io/cartsnitch/api:${{ steps.api_tag.outputs.tag }}
|
||||||
|
|
||||||
|
- name: Commit and push to infra
|
||||||
|
run: |
|
||||||
|
cd infra
|
||||||
|
git config user.name "cartsnitch-ci[bot]"
|
||||||
|
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
|
||||||
|
git add apps/overlays/uat/kustomization.yaml
|
||||||
|
git commit -m "ci(uat): update cartsnitch, auth, receiptwitness, and api images"
|
||||||
|
git pull --rebase origin main
|
||||||
git push origin main
|
git push origin main
|
||||||
|
|||||||
+5
-1
@@ -12,10 +12,14 @@ RUN pip install --no-cache-dir --prefix=/install .
|
|||||||
|
|
||||||
FROM python:3.12-slim AS prod
|
FROM python:3.12-slim AS prod
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN adduser --system --group --uid 1000 app
|
RUN adduser --system --group --uid 1000 app
|
||||||
COPY --from=build /install /usr/local
|
COPY --from=build /install /usr/local
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
|
COPY alembic.ini ./
|
||||||
|
COPY alembic/ ./alembic/
|
||||||
|
|
||||||
USER 1000
|
USER 1000
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
@@ -23,4 +27,4 @@ EXPOSE 8000
|
|||||||
HEALTHCHECK --interval=30s --timeout=3s \
|
HEALTHCHECK --interval=30s --timeout=3s \
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
||||||
|
|
||||||
CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn cartsnitch_api.main:app --host 0.0.0.0 --port 8000"]
|
||||||
|
|||||||
+14
-3
@@ -6,7 +6,7 @@ from logging.config import fileConfig
|
|||||||
from sqlalchemy import engine_from_config, pool
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
from cartsnitch_api.models import Base # noqa: F401 — imports all models for autogenerate
|
from cartsnitch_api.models.base import Base # noqa: F401 — imports all models for autogenerate
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
@@ -18,7 +18,7 @@ if not db_url:
|
|||||||
"CARTSNITCH_DATABASE_URL_SYNC must be set. "
|
"CARTSNITCH_DATABASE_URL_SYNC must be set. "
|
||||||
"Example: postgresql://user:pass@localhost:5432/cartsnitch"
|
"Example: postgresql://user:pass@localhost:5432/cartsnitch"
|
||||||
)
|
)
|
||||||
config.set_main_option("sqlalchemy.url", db_url)
|
config.set_main_option("sqlalchemy.url", db_url.replace("%", "%%"))
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ def run_migrations_offline() -> None:
|
|||||||
target_metadata=target_metadata,
|
target_metadata=target_metadata,
|
||||||
literal_binds=True,
|
literal_binds=True,
|
||||||
dialect_opts={"paramstyle": "named"},
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
version_table_column_width=128,
|
||||||
)
|
)
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
@@ -44,9 +45,19 @@ def run_migrations_online() -> None:
|
|||||||
poolclass=pool.NullPool,
|
poolclass=pool.NullPool,
|
||||||
)
|
)
|
||||||
with connectable.connect() as connection:
|
with connectable.connect() as connection:
|
||||||
context.configure(connection=connection, target_metadata=target_metadata)
|
context.configure(connection=connection, target_metadata=target_metadata, version_table_column_width=128)
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
|
# Create any tables defined in models but not yet created by migrations.
|
||||||
|
# This bootstraps fresh databases that have no legacy schema.
|
||||||
|
# checkfirst=True ensures this is a no-op on existing databases.
|
||||||
|
try:
|
||||||
|
Base.metadata.create_all(bind=connection, checkfirst=True)
|
||||||
|
except Exception as exc:
|
||||||
|
import logging
|
||||||
|
logging.getLogger("alembic.env").warning(
|
||||||
|
"create_all failed (non-fatal, migrations should handle table creation): %s", exc
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
if context.is_offline_mode():
|
||||||
|
|||||||
@@ -33,6 +33,21 @@ def _is_fernet_token(value: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
|
||||||
|
# Fresh DB — table created by Base.metadata.create_all with correct TEXT type
|
||||||
|
if not inspector.has_table("user_store_accounts"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Already migrated? Skip if session_data is already TEXT (not JSON)
|
||||||
|
cols = {c["name"]: c for c in inspector.get_columns("user_store_accounts")}
|
||||||
|
if "session_data" not in cols:
|
||||||
|
return
|
||||||
|
col_type = str(cols["session_data"]["type"]).lower()
|
||||||
|
if "text" in col_type and "json" not in col_type:
|
||||||
|
return # already TEXT — nothing to do
|
||||||
|
|
||||||
# Change column type from JSON to TEXT to hold Fernet ciphertext
|
# Change column type from JSON to TEXT to hold Fernet ciphertext
|
||||||
op.alter_column(
|
op.alter_column(
|
||||||
"user_store_accounts",
|
"user_store_accounts",
|
||||||
@@ -43,7 +58,6 @@ def upgrade() -> None:
|
|||||||
postgresql_using="session_data::text",
|
postgresql_using="session_data::text",
|
||||||
)
|
)
|
||||||
|
|
||||||
conn = op.get_bind()
|
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
text("SELECT id, session_data FROM user_store_accounts WHERE session_data IS NOT NULL")
|
text("SELECT id, session_data FROM user_store_accounts WHERE session_data IS NOT NULL")
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|||||||
@@ -21,81 +21,94 @@ depends_on = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
|
||||||
# --- Extend users table for Better-Auth compatibility ---
|
# --- Extend users table for Better-Auth compatibility ---
|
||||||
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"))
|
# Guard: on a fresh DB Base.metadata.create_all (called in env.py after migrations)
|
||||||
op.add_column("users", sa.Column("image", sa.Text(), nullable=True))
|
# creates the users table with all columns, so migration 002 must not re-run add_column.
|
||||||
|
if inspector.has_table("users"):
|
||||||
|
existing_user_cols = [c["name"] for c in inspector.get_columns("users")]
|
||||||
|
if "email_verified" not in existing_user_cols:
|
||||||
|
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"))
|
||||||
|
if "image" not in existing_user_cols:
|
||||||
|
op.add_column("users", sa.Column("image", sa.Text(), nullable=True))
|
||||||
|
|
||||||
# --- Create sessions table ---
|
# --- Create sessions table ---
|
||||||
op.create_table(
|
if not inspector.has_table("sessions"):
|
||||||
"sessions",
|
op.create_table(
|
||||||
sa.Column("id", sa.Text(), nullable=False),
|
"sessions",
|
||||||
sa.Column("token", sa.Text(), nullable=False),
|
sa.Column("id", sa.Text(), nullable=False),
|
||||||
sa.Column("user_id", sa.Text(), nullable=False),
|
sa.Column("token", sa.Text(), nullable=False),
|
||||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("user_id", sa.Text(), nullable=False),
|
||||||
sa.Column("ip_address", sa.Text(), nullable=True),
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("user_agent", sa.Text(), nullable=True),
|
sa.Column("ip_address", sa.Text(), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column("user_agent", sa.Text(), nullable=True),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
)
|
sa.PrimaryKeyConstraint("id"),
|
||||||
op.create_index("ix_sessions_token", "sessions", ["token"], unique=True)
|
)
|
||||||
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
|
op.create_index("ix_sessions_token", "sessions", ["token"], unique=True)
|
||||||
|
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
|
||||||
|
|
||||||
# --- Create accounts table ---
|
# --- Create accounts table ---
|
||||||
op.create_table(
|
if not inspector.has_table("accounts"):
|
||||||
"accounts",
|
op.create_table(
|
||||||
sa.Column("id", sa.Text(), nullable=False),
|
"accounts",
|
||||||
sa.Column("user_id", sa.Text(), nullable=False),
|
sa.Column("id", sa.Text(), nullable=False),
|
||||||
sa.Column("account_id", sa.Text(), nullable=False),
|
sa.Column("user_id", sa.Text(), nullable=False),
|
||||||
sa.Column("provider_id", sa.Text(), nullable=False),
|
sa.Column("account_id", sa.Text(), nullable=False),
|
||||||
sa.Column("access_token", sa.Text(), nullable=True),
|
sa.Column("provider_id", sa.Text(), nullable=False),
|
||||||
sa.Column("refresh_token", sa.Text(), nullable=True),
|
sa.Column("access_token", sa.Text(), nullable=True),
|
||||||
sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("refresh_token", sa.Text(), nullable=True),
|
||||||
sa.Column("refresh_token_expires_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("scope", sa.Text(), nullable=True),
|
sa.Column("refresh_token_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("id_token", sa.Text(), nullable=True),
|
sa.Column("scope", sa.Text(), nullable=True),
|
||||||
sa.Column("password", sa.Text(), nullable=True),
|
sa.Column("id_token", sa.Text(), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column("password", sa.Text(), nullable=True),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
)
|
sa.PrimaryKeyConstraint("id"),
|
||||||
op.create_index("ix_accounts_user_id", "accounts", ["user_id"])
|
)
|
||||||
|
op.create_index("ix_accounts_user_id", "accounts", ["user_id"])
|
||||||
|
|
||||||
# --- Create verifications table ---
|
# --- Create verifications table ---
|
||||||
op.create_table(
|
if not inspector.has_table("verifications"):
|
||||||
"verifications",
|
op.create_table(
|
||||||
sa.Column("id", sa.Text(), nullable=False),
|
"verifications",
|
||||||
sa.Column("identifier", sa.Text(), nullable=False),
|
sa.Column("id", sa.Text(), nullable=False),
|
||||||
sa.Column("value", sa.Text(), nullable=False),
|
sa.Column("identifier", sa.Text(), nullable=False),
|
||||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("value", sa.Text(), nullable=False),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
)
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
# --- Migrate existing password hashes to accounts table ---
|
# --- Migrate existing password hashes to accounts table ---
|
||||||
# For each user with a hashed_password, create a 'credential' account row
|
# Only run on existing (non-fresh) DBs that already have users table with data
|
||||||
conn = op.get_bind()
|
if inspector.has_table("users"):
|
||||||
users = conn.execute(
|
users = conn.execute(
|
||||||
text("SELECT id, hashed_password FROM users WHERE hashed_password IS NOT NULL")
|
text("SELECT id, hashed_password FROM users WHERE hashed_password IS NOT NULL")
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
for user_id, hashed_password in users:
|
for user_id, hashed_password in users:
|
||||||
user_id_str = str(user_id)
|
user_id_str = str(user_id)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
text(
|
text(
|
||||||
"INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) "
|
"INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) "
|
||||||
"VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())"
|
"VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())"
|
||||||
),
|
),
|
||||||
{"user_id": user_id_str, "account_id": user_id_str, "password": hashed_password},
|
{"user_id": user_id_str, "account_id": user_id_str, "password": hashed_password},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
op.drop_table("verifications")
|
op.execute(text("DROP INDEX IF EXISTS ix_accounts_user_id"))
|
||||||
op.drop_table("accounts")
|
op.execute(text("DROP TABLE IF EXISTS verifications"))
|
||||||
op.drop_index("ix_sessions_user_id", table_name="sessions")
|
op.execute(text("DROP TABLE IF EXISTS accounts"))
|
||||||
op.drop_index("ix_sessions_token", table_name="sessions")
|
op.execute(text("DROP INDEX IF EXISTS ix_sessions_user_id"))
|
||||||
op.drop_table("sessions")
|
op.execute(text("DROP INDEX IF EXISTS ix_sessions_token"))
|
||||||
op.drop_column("users", "image")
|
op.execute(text("DROP TABLE IF EXISTS sessions"))
|
||||||
op.drop_column("users", "email_verified")
|
op.execute(text("ALTER TABLE users DROP COLUMN IF EXISTS image"))
|
||||||
|
op.execute(text("ALTER TABLE users DROP COLUMN IF EXISTS email_verified"))
|
||||||
|
|||||||
@@ -19,8 +19,25 @@ depends_on = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=True)
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
|
||||||
|
# Fresh DB — nothing to alter
|
||||||
|
if not inspector.has_table("users"):
|
||||||
|
return
|
||||||
|
|
||||||
|
cols = {c["name"]: c for c in inspector.get_columns("users")}
|
||||||
|
if "hashed_password" in cols and not cols["hashed_password"]["nullable"]:
|
||||||
|
op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=False)
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
|
||||||
|
if not inspector.has_table("users"):
|
||||||
|
return
|
||||||
|
|
||||||
|
cols = {c["name"]: c for c in inspector.get_columns("users")}
|
||||||
|
if "hashed_password" in cols and cols["hashed_password"]["nullable"]:
|
||||||
|
op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=False)
|
||||||
|
|||||||
@@ -25,7 +25,21 @@ depends_on = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# Step 1: Drop existing FK constraints
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
|
||||||
|
# Fresh DB — no tables yet, nothing to convert
|
||||||
|
if not inspector.has_table("users"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if already TEXT (Base.metadata.create_all uses TEXT for fresh DB)
|
||||||
|
users_cols = {c["name"]: c for c in inspector.get_columns("users")}
|
||||||
|
if "id" in users_cols:
|
||||||
|
id_type = str(users_cols["id"]["type"]).lower()
|
||||||
|
if "text" in id_type and "uuid" not in id_type:
|
||||||
|
return # already TEXT — nothing to do
|
||||||
|
|
||||||
|
# Step 1: Drop existing FK constraints (ignore if they don't exist)
|
||||||
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
|
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
|
||||||
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
|
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ depends_on = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
# Guard: on a fresh DB Base.metadata.create_all creates users table with the column already present
|
||||||
|
if not inspector.has_table("users"):
|
||||||
|
return
|
||||||
|
existing_cols = [c["name"] for c in inspector.get_columns("users")]
|
||||||
|
if "email_inbound_token" in existing_cols:
|
||||||
|
return
|
||||||
|
|
||||||
# Add column nullable first so existing rows can be backfilled
|
# Add column nullable first so existing rows can be backfilled
|
||||||
op.add_column(
|
op.add_column(
|
||||||
"users",
|
"users",
|
||||||
@@ -25,11 +34,10 @@ def upgrade() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Backfill existing users with unique tokens
|
# Backfill existing users with unique tokens
|
||||||
connection = op.get_bind()
|
result = conn.execute(sa.text("SELECT id FROM users WHERE email_inbound_token IS NULL"))
|
||||||
result = connection.execute(sa.text("SELECT id FROM users WHERE email_inbound_token IS NULL"))
|
|
||||||
for (user_id,) in result:
|
for (user_id,) in result:
|
||||||
token = secrets.token_urlsafe(16)
|
token = secrets.token_urlsafe(16)
|
||||||
connection.execute(
|
conn.execute(
|
||||||
sa.text("UPDATE users SET email_inbound_token = :token WHERE id = :id"),
|
sa.text("UPDATE users SET email_inbound_token = :token WHERE id = :id"),
|
||||||
{"token": token, "id": user_id},
|
{"token": token, "id": user_id},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Add server_default to users.email_inbound_token.
|
||||||
|
|
||||||
|
Revision ID: 006_email_inbound_token_server_default
|
||||||
|
Revises: 005_add_email_inbound_token
|
||||||
|
Create Date: 2026-04-04
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "006_email_inbound_token_server_default"
|
||||||
|
down_revision = "005_add_email_inbound_token"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
# Guard: on a fresh DB Base.metadata.create_all already sets the server_default
|
||||||
|
if not inspector.has_table("users"):
|
||||||
|
return
|
||||||
|
cols = {c["name"]: c for c in inspector.get_columns("users")}
|
||||||
|
if "email_inbound_token" not in cols:
|
||||||
|
return
|
||||||
|
if cols["email_inbound_token"].get("default") is not None:
|
||||||
|
return
|
||||||
|
op.alter_column(
|
||||||
|
"users",
|
||||||
|
"email_inbound_token",
|
||||||
|
server_default=sa.text(
|
||||||
|
"replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.alter_column(
|
||||||
|
"users",
|
||||||
|
"email_inbound_token",
|
||||||
|
server_default=None,
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Bootstrap users table on fresh databases.
|
||||||
|
|
||||||
|
On fresh databases, migrations 001-006 skip users-table operations because
|
||||||
|
the table does not exist yet. Base.metadata.create_all() in env.py is meant
|
||||||
|
to handle this, but if it fails (import errors, etc.) the table is never
|
||||||
|
created. This migration creates the users table with raw SQL as a safety net.
|
||||||
|
|
||||||
|
Revision ID: 007_bootstrap_users_table
|
||||||
|
Revises: 006_email_inbound_token_server_default
|
||||||
|
Create Date: 2026-04-04
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "007_bootstrap_users_table"
|
||||||
|
down_revision = "006_email_inbound_token_server_default"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
if inspector.has_table("users"):
|
||||||
|
return # Table already exists (non-fresh DB or create_all already ran)
|
||||||
|
|
||||||
|
conn.execute(text("""
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
hashed_password VARCHAR(255),
|
||||||
|
display_name VARCHAR(100),
|
||||||
|
email_verified BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
image TEXT,
|
||||||
|
email_inbound_token VARCHAR(22) NOT NULL UNIQUE
|
||||||
|
DEFAULT replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_'),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute(text("DROP TABLE IF EXISTS users"))
|
||||||
@@ -22,11 +22,6 @@ from cartsnitch_api.services.auth import AuthService
|
|||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
class EmailInAddressResponse(BaseModel):
|
|
||||||
email_address: str
|
|
||||||
instructions: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
@router.get("/me", response_model=UserResponse)
|
||||||
async def get_me(
|
async def get_me(
|
||||||
user_id: str = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
@@ -70,23 +65,3 @@ async def delete_me(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||||
) from None
|
) from None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/email-in-address", response_model=EmailInAddressResponse)
|
|
||||||
async def get_email_in_address(
|
|
||||||
user_id: str = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
result = await db.execute(select(User.email_inbound_token).where(User.id == user_id))
|
|
||||||
token = result.scalar_one_or_none()
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Email inbound token not found"
|
|
||||||
) from None
|
|
||||||
return EmailInAddressResponse(
|
|
||||||
email_address=f"receipts+{token}@receipts.cartsnitch.com",
|
|
||||||
instructions=(
|
|
||||||
"Forward your digital receipt emails to this address. "
|
|
||||||
"We currently support Meijer, Kroger, and Target receipt emails."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import secrets
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from cartsnitch_api.constants import AccountStatus
|
from cartsnitch_api.constants import AccountStatus
|
||||||
@@ -23,13 +24,20 @@ class User(TimestampMixin, Base):
|
|||||||
|
|
||||||
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||||
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
display_name: Mapped[str | None] = mapped_column(String(100))
|
display_name: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
email_verified: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, server_default="false"
|
||||||
|
)
|
||||||
|
image: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
email_inbound_token: Mapped[str] = mapped_column(
|
email_inbound_token: Mapped[str] = mapped_column(
|
||||||
String(22),
|
String(22),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
unique=True,
|
unique=True,
|
||||||
default=lambda: secrets.token_urlsafe(16),
|
default=lambda: secrets.token_urlsafe(16),
|
||||||
|
server_default=sa.text(
|
||||||
|
"replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ async def get_email_in_address(
|
|||||||
svc = AuthService(db)
|
svc = AuthService(db)
|
||||||
try:
|
try:
|
||||||
email_address = await svc.get_email_in_address(user_id)
|
email_address = await svc.get_email_in_address(user_id)
|
||||||
return EmailInAddressResponse(email_address=email_address)
|
return EmailInAddressResponse(
|
||||||
|
email_address=email_address,
|
||||||
|
instructions=(
|
||||||
|
"Forward your digital receipt emails to this address. "
|
||||||
|
"We currently support Meijer, Kroger, and Target receipt emails."
|
||||||
|
),
|
||||||
|
)
|
||||||
except LookupError:
|
except LookupError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class UserResponse(BaseModel):
|
|||||||
|
|
||||||
class EmailInAddressResponse(BaseModel):
|
class EmailInAddressResponse(BaseModel):
|
||||||
email_address: str
|
email_address: str
|
||||||
|
instructions: str
|
||||||
|
|
||||||
|
|
||||||
# ---------- Stores ----------
|
# ---------- Stores ----------
|
||||||
|
|||||||
@@ -76,4 +76,4 @@ class AuthService:
|
|||||||
if not user:
|
if not user:
|
||||||
raise LookupError("User not found")
|
raise LookupError("User not found")
|
||||||
|
|
||||||
return f"{user.email_inbound_token}@email.cartsnitch.com"
|
return f"receipts+{user.email_inbound_token}@receipts.cartsnitch.com"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for GET /auth/me/email-in-address endpoint."""
|
"""Tests for GET /api/v1/me/email-in-address endpoint."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
@@ -8,7 +8,7 @@ from httpx import AsyncClient
|
|||||||
async def test_get_email_in_address_authenticated(client: AsyncClient, auth_headers: dict):
|
async def test_get_email_in_address_authenticated(client: AsyncClient, auth_headers: dict):
|
||||||
"""Authenticated user gets their email-in address."""
|
"""Authenticated user gets their email-in address."""
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/auth/me/email-in-address",
|
"/api/v1/me/email-in-address",
|
||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ async def test_get_email_in_address_authenticated(client: AsyncClient, auth_head
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_email_in_address_unauthenticated(client: AsyncClient):
|
async def test_get_email_in_address_unauthenticated(client: AsyncClient):
|
||||||
"""Unauthenticated request returns 401."""
|
"""Unauthenticated request returns 401."""
|
||||||
response = await client.get("/auth/me/email-in-address")
|
response = await client.get("/api/v1/me/email-in-address")
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ async def test_get_email_in_address_unauthenticated(client: AsyncClient):
|
|||||||
async def test_get_email_in_address_invalid_token(client: AsyncClient):
|
async def test_get_email_in_address_invalid_token(client: AsyncClient):
|
||||||
"""Invalid JWT token returns 401."""
|
"""Invalid JWT token returns 401."""
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/auth/me/email-in-address",
|
"/api/v1/me/email-in-address",
|
||||||
headers={"Authorization": "Bearer invalid-token-xyz"},
|
headers={"Authorization": "Bearer invalid-token-xyz"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
@@ -45,7 +45,7 @@ async def test_get_email_in_address_invalid_token(client: AsyncClient):
|
|||||||
async def test_email_address_format(client: AsyncClient, auth_headers: dict):
|
async def test_email_address_format(client: AsyncClient, auth_headers: dict):
|
||||||
"""Email address format is receipts+{22-char-urlsafe-token}@receipts.cartsnitch.com."""
|
"""Email address format is receipts+{22-char-urlsafe-token}@receipts.cartsnitch.com."""
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/auth/me/email-in-address",
|
"/api/v1/me/email-in-address",
|
||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -95,5 +95,6 @@ export const auth = betterAuth({
|
|||||||
"https://cartsnitch.com",
|
"https://cartsnitch.com",
|
||||||
"https://cartsnitch.farh.net",
|
"https://cartsnitch.farh.net",
|
||||||
"https://cartsnitch.dev.farh.net",
|
"https://cartsnitch.dev.farh.net",
|
||||||
|
"https://cartsnitch.uat.farh.net",
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ if config.config_file_name is not None:
|
|||||||
|
|
||||||
db_url = os.environ.get("CARTSNITCH_DATABASE_URL_SYNC")
|
db_url = os.environ.get("CARTSNITCH_DATABASE_URL_SYNC")
|
||||||
if db_url:
|
if db_url:
|
||||||
config.set_main_option("sqlalchemy.url", db_url)
|
config.set_main_option("sqlalchemy.url", db_url.replace("%", "%%"))
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Add email_inbound_token to users.
|
||||||
|
|
||||||
|
Revision ID: 001_add_email_inbound_token
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-04-02
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "001_add_email_inbound_token"
|
||||||
|
down_revision: str | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("email_inbound_token", sa.String(22), nullable=True))
|
||||||
|
op.create_unique_constraint("uq_users_email_inbound_token", "users", ["email_inbound_token"])
|
||||||
|
|
||||||
|
# Backfill existing users with generated tokens (PostgreSQL)
|
||||||
|
op.execute(
|
||||||
|
"UPDATE users SET email_inbound_token = "
|
||||||
|
"substring(replace(gen_random_uuid()::text, '-', ''), 1, 22) "
|
||||||
|
"WHERE email_inbound_token IS NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Alter to non-nullable
|
||||||
|
op.alter_column("users", "email_inbound_token", nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("uq_users_email_inbound_token", "users", type_="unique")
|
||||||
|
op.drop_column("users", "email_inbound_token")
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
"""User and UserStoreAccount models."""
|
"""User and UserStoreAccount models."""
|
||||||
|
|
||||||
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint
|
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from cartsnitch_common.constants import AccountStatus
|
from cartsnitch_common.constants import AccountStatus
|
||||||
@@ -21,6 +22,15 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||||
|
email_inbound_token: Mapped[str] = mapped_column(
|
||||||
|
String(22),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
default=lambda: secrets.token_urlsafe(16),
|
||||||
|
server_default=text(
|
||||||
|
"replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
|
||||||
|
),
|
||||||
|
)
|
||||||
hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
display_name: Mapped[str | None] = mapped_column(String(100))
|
display_name: Mapped[str | None] = mapped_column(String(100))
|
||||||
email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class UserRead(BaseModel):
|
|||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
email: str
|
email: str
|
||||||
display_name: str | None
|
display_name: str | None
|
||||||
|
email_inbound_token: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,40 @@ class TestStoreLocationModel:
|
|||||||
assert loc.lat == pytest.approx(42.2808)
|
assert loc.lat == pytest.approx(42.2808)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserModel:
|
||||||
|
def test_email_inbound_token_auto_populated(self, session):
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="token_test@example.com",
|
||||||
|
hashed_password="hashed",
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
updated_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
assert user.email_inbound_token is not None
|
||||||
|
assert len(user.email_inbound_token) == 22
|
||||||
|
|
||||||
|
def test_email_inbound_token_unique(self, session):
|
||||||
|
user1 = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="user1@example.com",
|
||||||
|
hashed_password="hashed",
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
updated_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
user2 = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="user2@example.com",
|
||||||
|
hashed_password="hashed",
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
updated_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
session.add_all([user1, user2])
|
||||||
|
session.commit()
|
||||||
|
assert user1.email_inbound_token != user2.email_inbound_token
|
||||||
|
|
||||||
|
|
||||||
class TestUserStoreAccountModel:
|
class TestUserStoreAccountModel:
|
||||||
def test_account_status_enum(self, session):
|
def test_account_status_enum(self, session):
|
||||||
user = User(
|
user = User(
|
||||||
|
|||||||
Generated
+548
-650
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -50,6 +50,9 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"@rollup/pluginutils": "5.3.0",
|
"@rollup/pluginutils": "5.3.0",
|
||||||
"flatted": "^3.4.2",
|
"flatted": "^3.4.2",
|
||||||
"serialize-javascript": "7.0.5"
|
"serialize-javascript": "7.0.5",
|
||||||
|
"brace-expansion": ">=1.1.13",
|
||||||
|
"lodash": ">=4.17.24",
|
||||||
|
"minimatch": "^10.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user