Sfoglia il codice sorgente

merge with master and fix merge conflicts

Alexander Belanger 4 anni fa
parent
commit
f872cb66ff
91 ha cambiato i file con 3001 aggiunte e 970 eliminazioni
  1. 398 0
      .github/workflows/prerelease.yaml
  2. 58 342
      .github/workflows/release.yaml
  3. 44 17
      api/client/api.go
  4. 3 0
      api/client/deploy.go
  5. 221 45
      api/server/handlers/kube_events/create.go
  6. 7 1
      api/server/handlers/namespace/stream_pod_logs.go
  7. 1 0
      api/server/handlers/release/create.go
  8. 5 3
      api/server/handlers/release/ugprade.go
  9. 5 3
      api/server/handlers/release/upgrade_webhook.go
  10. 52 0
      api/server/handlers/user/can_create_project.go
  11. 25 0
      api/server/router/user.go
  12. 5 3
      api/server/shared/apierrors/errors.go
  13. 11 1
      api/server/shared/config/env/envconfs.go
  14. 1 1
      api/server/shared/requestutils/decoder.go
  15. 5 0
      api/types/namespace.go
  16. 4 0
      cli/cmd/config.go
  17. 19 0
      cli/cmd/create.go
  18. 22 0
      cli/cmd/deploy.go
  19. 5 0
      cli/cmd/deploy/create.go
  20. 12 1
      cli/cmd/deploy/deploy.go
  21. 1 0
      cli/cmd/deploy/shared.go
  22. 4 0
      cli/cmd/docker/builder.go
  23. 86 6
      cli/cmd/pack/pack.go
  24. 1 1
      dashboard/babel.config.json
  25. 24 0
      dashboard/package-lock.json
  26. 1 0
      dashboard/package.json
  27. 121 0
      dashboard/src/components/DocsHelper.tsx
  28. 2 2
      dashboard/src/components/events/useLastSeenPodStatus.ts
  29. 2 295
      dashboard/src/components/repo-selector/ActionDetails.tsx
  30. 465 0
      dashboard/src/components/repo-selector/BuildpackSelection.tsx
  31. 2 2
      dashboard/src/components/repo-selector/ContentsList.tsx
  32. 21 0
      dashboard/src/main/home/Home.tsx
  33. 1 1
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  34. 1 1
      dashboard/src/main/home/cluster-dashboard/LastRunStatusSelector.tsx
  35. 0 1
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  36. 286 16
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  37. 2 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  38. 6 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  39. 6 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/TempJobList.tsx
  40. 2 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  41. 127 31
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  42. 9 2
      dashboard/src/main/home/launch/Launch.tsx
  43. 1 1
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  44. 1 1
      dashboard/src/main/home/launch/launch-flow/SourcePage.tsx
  45. 1 1
      dashboard/src/main/home/launch/launch-flow/WorkflowPage.tsx
  46. 12 2
      dashboard/src/main/home/new-project/NewProject.tsx
  47. 13 8
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/ConnectRegistry.tsx
  48. 35 4
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/FormFlow.tsx
  49. 23 6
      dashboard/src/main/home/onboarding/steps/ConnectSource.tsx
  50. 14 1
      dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx
  51. 43 4
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/FormFlow.tsx
  52. 31 4
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/StatusPage.tsx
  53. 1 1
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_AWSProvisionerForm.tsx
  54. 5 1
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_ConnectExternalCluster.tsx
  55. 1 0
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_GCPProvisionerForm.tsx
  56. 1 0
      dashboard/src/main/home/onboarding/types.ts
  57. 12 10
      dashboard/src/main/home/sidebar/ProjectSection.tsx
  58. 6 0
      dashboard/src/shared/Context.tsx
  59. 1 0
      dashboard/src/shared/anayltics/index.ts
  60. 29 0
      dashboard/src/shared/anayltics/onboarding/events.ts
  61. 88 0
      dashboard/src/shared/anayltics/onboarding/tracks.ts
  62. 35 0
      dashboard/src/shared/anayltics/onboarding/types.ts
  63. 6 0
      dashboard/src/shared/api.tsx
  64. 2 0
      dashboard/src/shared/types.tsx
  65. 4 0
      docs/guides/preserving-client-ip-addresses.md
  66. 58 3
      internal/helm/agent.go
  67. 77 77
      internal/helm/agent_test.go
  68. 21 5
      internal/integrations/ci/actions/actions.go
  69. 11 0
      internal/integrations/slack/notifier.go
  70. 15 8
      internal/kubernetes/agent.go
  71. 12 0
      internal/models/allowlist.go
  72. 17 0
      internal/models/notification.go
  73. 7 0
      internal/repository/allowlist.go
  74. 33 0
      internal/repository/gorm/allowlist.go
  75. 49 0
      internal/repository/gorm/allowlist_test.go
  76. 2 7
      internal/repository/gorm/event.go
  77. 18 1
      internal/repository/gorm/helpers_test.go
  78. 2 0
      internal/repository/gorm/migrate.go
  79. 62 0
      internal/repository/gorm/notification.go
  80. 12 0
      internal/repository/gorm/repository.go
  81. 6 0
      internal/repository/notification.go
  82. 2 0
      internal/repository/repository.go
  83. 41 0
      internal/repository/test/allowlist.go
  84. 18 0
      internal/repository/test/notification.go
  85. 12 0
      internal/repository/test/repository.go
  86. 7 3
      internal/usage/usage.go
  87. 1 1
      services/job_sidecar_container/Dockerfile
  88. 44 33
      services/job_sidecar_container/job_killer.sh
  89. 15 4
      services/job_sidecar_container/sidecar_killer.sh
  90. 14 0
      services/job_sidecar_container/wait_for_job.sh
  91. 7 4
      services/usage/usage.go

+ 398 - 0
.github/workflows/prerelease.yaml

@@ -0,0 +1,398 @@
+on:
+  push:
+    tags:
+      - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
+name: Create prerelease w/ binaries and docker image
+jobs:
+  docker-build-push:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Get tag name
+        id: tag_name
+        run: |
+          tag=${GITHUB_TAG/refs\/tags\//}
+          echo ::set-output name=tag::$tag
+        env:
+          GITHUB_TAG: ${{ github.ref }}
+      - name: Checkout
+        uses: actions/checkout@v2.3.4
+      - name: Setup docker
+        uses: docker/login-action@v1
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_TOKEN }}
+      - name: Write Dashboard Environment Variables
+        run: |
+          cat >./dashboard/.env <<EOL
+          NODE_ENV=production
+          APPLICATION_CHART_REPO_URL=https://charts.getporter.dev
+          ADDON_CHART_REPO_URL=https://chart-addons.getporter.dev
+          EOL
+
+          cat ./dashboard/.env
+      - name: Build
+        run: |
+          DOCKER_BUILDKIT=1 docker build . -t porter1/porter:${{steps.tag_name.outputs.tag}} -f ./ee/docker/ee.Dockerfile --build-arg version=${{steps.tag_name.outputs.tag}}
+      - name: Push
+        run: |
+          docker push porter1/porter:${{steps.tag_name.outputs.tag}}
+  build-linux:
+    name: Build Linux binaries
+    runs-on: ubuntu-latest
+    steps:
+      - name: Get tag name
+        id: tag_name
+        run: |
+          tag=${GITHUB_TAG/refs\/tags\//}
+          echo ::set-output name=tag::$tag
+        env:
+          GITHUB_TAG: ${{ github.ref }}
+      - name: Checkout code
+        uses: actions/checkout@v2
+      - name: Set up Go
+        uses: actions/setup-go@v2
+        with:
+          go-version: 1.16
+      - name: Write Dashboard Environment Variables
+        run: |
+          cat >./dashboard/.env <<EOL
+          NODE_ENV=production
+          APPLICATION_CHART_REPO_URL=https://charts.getporter.dev
+          ADDON_CHART_REPO_URL=https://chart-addons.getporter.dev
+          EOL
+      - name: Build and zip static folder
+        run: |
+          mkdir -p ./release/static
+          cd dashboard
+          npm i --production=false
+          npm run build
+          cd ..
+          zip --junk-paths ./release/static/static_${{steps.tag_name.outputs.tag}}.zip ./dashboard/build/*
+        env:
+          NODE_ENV: production
+      - name: Build Linux binaries
+        run: |
+          go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${{steps.tag_name.outputs.tag}}'" -a -tags cli -o ./porter ./cli &
+          go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -o ./docker-credential-porter ./cmd/docker-credential-porter/ &
+          go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -tags ee -o ./portersvr ./cmd/app/ &
+          wait
+        env:
+          GOOS: linux
+          GOARCH: amd64
+          CGO_ENABLED: 1
+      # Note: we have to zip all binaries before uploading them as artifacts --
+      # without this step, the binaries will be uploaded but the file metadata will
+      # be listed as plaintext after downloading the artifact in a later step
+      #
+      # TODO: investigate
+      - name: Zip Linux binaries
+        run: |
+          mkdir -p ./release/linux
+          zip --junk-paths ./release/linux/porter_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip ./porter
+          zip --junk-paths ./release/linux/portersvr_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip ./portersvr
+          zip --junk-paths ./release/linux/docker-credential-porter_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip ./docker-credential-porter
+      - name: Upload binaries
+        uses: actions/upload-artifact@v2
+        with:
+          path: ./release/linux
+          name: linux-binaries
+          retention-days: 1
+      - name: Upload static binaries
+        uses: actions/upload-artifact@v2
+        with:
+          path: ./release/static
+          name: static-binaries
+          retention-days: 1
+  build-mac:
+    name: Build MacOS binaries
+    runs-on: macos-11
+    steps:
+      - name: Get tag name
+        id: tag_name
+        run: |
+          tag=${GITHUB_TAG/refs\/tags\//}
+          echo ::set-output name=tag::$tag
+        env:
+          GITHUB_TAG: ${{ github.ref }}
+      - name: Checkout code
+        uses: actions/checkout@v2
+      - name: Set up Go
+        uses: actions/setup-go@v2
+        with:
+          go-version: 1.16
+      - name: Write Dashboard Environment Variables
+        run: |
+          cat >./dashboard/.env <<EOL
+          NODE_ENV=production
+          APPLICATION_CHART_REPO_URL=https://charts.getporter.dev
+          ADDON_CHART_REPO_URL=https://chart-addons.getporter.dev
+          EOL
+      - name: Build and Zip MacOS amd64 binaries
+        run: |
+          go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${{steps.tag_name.outputs.tag}}'" -a -tags cli -o ./amd64/porter ./cli &
+          go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -o ./amd64/docker-credential-porter ./cmd/docker-credential-porter/ &
+          go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -tags ee -o ./amd64/portersvr ./cmd/app/ &
+          wait
+
+          mkdir -p ./release/darwin
+          zip --junk-paths ./release/darwin/UNSIGNED_porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip ./amd64/porter
+          zip --junk-paths ./release/darwin/UNSIGNED_portersvr_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip ./amd64/portersvr
+          zip --junk-paths ./release/darwin/UNSIGNED_docker-credential-porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip ./amd64/docker-credential-porter
+        env:
+          GOOS: darwin
+          GOARCH: amd64
+          CGO_ENABLED: 1
+      - name: Upload binaries
+        uses: actions/upload-artifact@v2
+        with:
+          path: ./release/darwin
+          name: mac-binaries
+          retention-days: 1
+  notarize:
+    name: Notarize Darwin binaries
+    runs-on: macos-11
+    needs: build-mac
+    steps:
+      - name: Get tag name
+        id: tag_name
+        run: |
+          tag=${GITHUB_TAG/refs\/tags\//}
+          echo ::set-output name=tag::$tag
+        env:
+          GITHUB_TAG: ${{ github.ref }}
+      - name: Download binaries
+        uses: actions/download-artifact@v2
+        with:
+          name: mac-binaries
+          path: release/
+      - name: Unzip Darwin binaries
+        run: |
+          unzip ./release/UNSIGNED_porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
+          unzip ./release/UNSIGNED_portersvr_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
+          unzip ./release/UNSIGNED_docker-credential-porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
+      - name: Import Code-Signing Certificates
+        uses: Apple-Actions/import-codesign-certs@v1
+        with:
+          # The certificates in a PKCS12 file encoded as a base64 string
+          p12-file-base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}
+          # The password used to import the PKCS12 file.
+          p12-password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
+      - name: Install gon via HomeBrew for code signing and app notarization
+        run: |
+          brew tap mitchellh/gon
+          brew install mitchellh/gon/gon
+      - name: Create a porter.gon.json file
+        run: |
+          echo "
+          {
+              \"source\": [\"./porter\"],
+              \"bundle_id\": \"cli.porter\",
+              \"apple_id\": {
+                  \"password\":  \"@env:AC_PASSWORD\"
+              },
+              \"sign\": {
+                  \"application_identity\": \"${{ secrets.AC_APPLICATION_IDENTITY }}\"
+              },
+              \"zip\": {
+                  \"output_path\": \"./release/porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip\"
+              }
+          }
+          " > ./porter.gon.json
+      - name: Create a portersvr.gon.json file
+        run: |
+          echo "
+          {
+              \"source\": [\"./portersvr\"],
+              \"bundle_id\": \"cli.portersvr\",
+              \"apple_id\": {
+                  \"password\":  \"@env:AC_PASSWORD\"
+              },
+              \"sign\": {
+                  \"application_identity\": \"${{ secrets.AC_APPLICATION_IDENTITY }}\"
+              },
+              \"zip\": {
+                  \"output_path\": \"./release/portersvr_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip\"
+              }
+          }
+          " > ./portersvr.gon.json
+      - name: Create a docker-credential-porter.gon.json file
+        run: |
+          echo "
+          {
+              \"source\": [\"./docker-credential-porter\"],
+              \"bundle_id\": \"cli.docker-credential-porter\",
+              \"apple_id\": {
+                  \"password\":  \"@env:AC_PASSWORD\"
+              },
+              \"sign\": {
+                  \"application_identity\": \"${{ secrets.AC_APPLICATION_IDENTITY }}\"
+              },
+              \"zip\": {
+                  \"output_path\": \"./release/docker-credential-porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip\"
+              }
+          }
+          " > ./docker-credential-porter.gon.json
+      - name: Sign the mac binaries with Gon
+        env:
+          AC_USERNAME: ${{ secrets.AC_USERNAME }}
+          AC_PASSWORD: ${{ secrets.AC_PASSWORD }}
+        run: |
+          gon ./porter.gon.json &
+          gon ./portersvr.gon.json &
+          gon ./docker-credential-porter.gon.json &
+          wait
+      - name: Upload binaries
+        uses: actions/upload-artifact@v2
+        with:
+          path: ./release
+          name: mac-binaries
+          retention-days: 1
+  release:
+    name: Zip binaries, create release and upload assets
+    runs-on: ubuntu-latest
+    needs: 
+    - notarize
+    - build-linux
+    steps:
+      - name: Get tag name
+        id: tag_name
+        run: |
+          tag=${GITHUB_TAG/refs\/tags\//}
+          echo ::set-output name=tag::$tag
+        env:
+          GITHUB_TAG: ${{ github.ref }}
+      - name: Download binaries
+        uses: actions/download-artifact@v2
+        with:
+          name: linux-binaries
+          path: release/linux
+      - name: Download binaries
+        uses: actions/download-artifact@v2
+        with:
+          name: static-binaries
+          path: release/static
+      - name: Download binaries
+        uses: actions/download-artifact@v2
+        with:
+          name: mac-binaries
+          path: release/darwin
+      - name: Create Release
+        id: create_release
+        uses: actions/create-release@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          tag_name: ${{ github.ref }}
+          release_name: Release ${{ github.ref }}
+          draft: false
+          prerelease: true
+      - name: Upload Linux CLI Release Asset
+        id: upload-linux-cli-release-asset
+        uses: actions/upload-release-asset@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GITHUB_TAG: ${{ github.ref }}
+        with:
+          upload_url: ${{ steps.create_release.outputs.upload_url }}
+          asset_path: ./release/linux/porter_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip
+          asset_name: porter_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip
+          asset_content_type: application/zip
+      - name: Upload Linux Server Release Asset
+        id: upload-linux-server-release-asset
+        uses: actions/upload-release-asset@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GITHUB_TAG: ${{ github.ref }}
+        with:
+          upload_url: ${{ steps.create_release.outputs.upload_url }}
+          asset_path: ./release/linux/portersvr_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip
+          asset_name: portersvr_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip
+          asset_content_type: application/zip
+      - name: Upload Linux Docker Credential Release Asset
+        id: upload-linux-docker-cred-release-asset
+        uses: actions/upload-release-asset@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GITHUB_TAG: ${{ github.ref }}
+        with:
+          upload_url: ${{ steps.create_release.outputs.upload_url }}
+          asset_path: ./release/linux/docker-credential-porter_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip
+          asset_name: docker-credential-porter_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip
+          asset_content_type: application/zip
+      - name: Upload Darwin CLI Release Asset
+        id: upload-darwin-cli-release-asset
+        uses: actions/upload-release-asset@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GITHUB_TAG: ${{ github.ref }}
+        with:
+          upload_url: ${{ steps.create_release.outputs.upload_url }}
+          asset_path: ./release/darwin/porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
+          asset_name: porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
+          asset_content_type: application/zip
+      - name: Upload Darwin Server Release Asset
+        id: upload-darwin-server-release-asset
+        uses: actions/upload-release-asset@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GITHUB_TAG: ${{ github.ref }}
+        with:
+          upload_url: ${{ steps.create_release.outputs.upload_url }}
+          asset_path: ./release/darwin/portersvr_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
+          asset_name: portersvr_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
+          asset_content_type: application/zip
+      - name: Upload Darwin Docker Credential Release Asset
+        id: upload-darwin-docker-cred-release-asset
+        uses: actions/upload-release-asset@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GITHUB_TAG: ${{ github.ref }}
+        with:
+          upload_url: ${{ steps.create_release.outputs.upload_url }}
+          asset_path: ./release/darwin/docker-credential-porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
+          asset_name: docker-credential-porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
+          asset_content_type: application/zip
+      - name: Upload Static Release Asset
+        id: upload-static-release-asset
+        uses: actions/upload-release-asset@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GITHUB_TAG: ${{ github.ref }}
+        with:
+          upload_url: ${{ steps.create_release.outputs.upload_url }}
+          asset_path: ./release/static/static_${{steps.tag_name.outputs.tag}}.zip
+          asset_name: static_${{steps.tag_name.outputs.tag}}.zip
+          asset_content_type: application/zip
+  build-push-docker-cli:
+    name: Build a new porter-cli docker image
+    runs-on: ubuntu-latest
+    needs: release
+    steps:
+      - name: Get tag name
+        id: tag_name
+        run: |
+          tag=${GITHUB_TAG/refs\/tags\//}
+          echo ::set-output name=tag::$tag
+        env:
+          GITHUB_TAG: ${{ github.ref }}
+      - name: Checkout
+        uses: actions/checkout@v2.3.4
+      - name: Configure AWS credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.ECR_AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.ECR_AWS_SECRET_ACCESS_KEY }}
+          aws-region: us-east-2
+      - name: Login to ECR public
+        id: login-ecr
+        run: |
+          aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/o1j4x7p4
+      - name: Build
+        run: |
+          docker build ./services/porter_cli_container \
+            -t public.ecr.aws/o1j4x7p4/porter-cli:${{steps.tag_name.outputs.tag}} \
+            -f ./services/porter_cli_container/Dockerfile \
+            --build-arg VERSION=${{steps.tag_name.outputs.tag}}
+      - name: Push
+        run: |
+          docker push public.ecr.aws/o1j4x7p4/porter-cli:${{steps.tag_name.outputs.tag}}

+ 58 - 342
.github/workflows/release.yaml

@@ -1,12 +1,9 @@
 on:
-  push:
-    tags:
-      - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
-
-name: Create release w/ binaries and docker image
-
+  release:
+    types: [released]
+name: Update binaries 
 jobs:
-  docker-build-push:
+  push-docker-server-latest:
     runs-on: ubuntu-latest
     steps:
       - name: Get tag name
@@ -16,30 +13,18 @@ jobs:
           echo ::set-output name=tag::$tag
         env:
           GITHUB_TAG: ${{ github.ref }}
-      - name: Checkout
-        uses: actions/checkout@v2.3.4
       - name: Setup docker
         uses: docker/login-action@v1
         with:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
-      - name: Write Dashboard Environment Variables
-        run: |
-          cat >./dashboard/.env <<EOL
-          NODE_ENV=production
-          APPLICATION_CHART_REPO_URL=https://charts.getporter.dev
-          ADDON_CHART_REPO_URL=https://chart-addons.getporter.dev
-          EOL
-
-          cat ./dashboard/.env
-      - name: Build
-        run: |
-          DOCKER_BUILDKIT=1 docker build . -t porter1/porter:${{steps.tag_name.outputs.tag}} -f ./ee/docker/ee.Dockerfile --build-arg version=${{steps.tag_name.outputs.tag}}
-      - name: Push
+      - name: Pull versioned server image and push to latest
         run: |
-          docker push porter1/porter:${{steps.tag_name.outputs.tag}}
-  build:
-    name: Build binaries
+          docker pull porter1/porter:${{steps.tag_name.outputs.tag}}
+          docker tag porter1/porter:${{steps.tag_name.outputs.tag}} porter1/porter:latest
+          docker push porter1/porter:latest
+  push-docker-cli-latest:
+    name: Build a new porter-cli docker image
     runs-on: ubuntu-latest
     steps:
       - name: Get tag name
@@ -49,308 +34,24 @@ jobs:
           echo ::set-output name=tag::$tag
         env:
           GITHUB_TAG: ${{ github.ref }}
-      - name: Checkout code
-        uses: actions/checkout@v2
-      - name: Set up Go
-        uses: actions/setup-go@v2
-        with:
-          go-version: 1.15
-      - name: Write Dashboard Environment Variables
-        run: |
-          cat >./dashboard/.env <<EOL
-          NODE_ENV=production
-          APPLICATION_CHART_REPO_URL=https://charts.getporter.dev
-          ADDON_CHART_REPO_URL=https://chart-addons.getporter.dev
-          EOL
-      - name: Build and zip static folder
-        run: |
-          mkdir -p ./release/static
-          cd dashboard
-          npm i --production=false
-          npm run build
-          cd ..
-          zip --junk-paths ./release/static/static_${{steps.tag_name.outputs.tag}}.zip ./dashboard/build/*
-        env:
-          NODE_ENV: production
-      - name: Build Linux binaries
-        run: |
-          go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${{steps.tag_name.outputs.tag}}'" -a -tags cli -o ./porter ./cli &
-          go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -o ./docker-credential-porter ./cmd/docker-credential-porter/ &
-          go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -tags ee -o ./portersvr ./cmd/app/ &
-          wait
-        env:
-          GOOS: linux
-          GOARCH: amd64
-          CGO_ENABLED: 1
-      # Note: we have to zip all binaries before uploading them as artifacts --
-      # without this step, the binaries will be uploaded but the file metadata will
-      # be listed as plaintext after downloading the artifact in a later step
-      #
-      # TODO: investigate
-      - name: Zip Linux binaries
-        run: |
-          mkdir -p ./release/linux
-          zip --junk-paths ./release/linux/porter_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip ./porter
-          zip --junk-paths ./release/linux/portersvr_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip ./portersvr
-          zip --junk-paths ./release/linux/docker-credential-porter_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip ./docker-credential-porter
-      - name: Build and zip Darwin binaries
-        run: |
-          docker build . --file ./build/Dockerfile.osx -t osx
-          docker run \
-          --mount type=bind,source="$(pwd)"/release,target=/release \
-          osx:latest ${{steps.tag_name.outputs.tag}}
-      - name: Build and zip Windows binaries
-        run: |
-          docker build . --file ./build/Dockerfile.win -t win
-          docker run \
-          --mount type=bind,source="$(pwd)"/release,target=/release \
-          win:latest ${{steps.tag_name.outputs.tag}}
-      - name: Upload binaries
-        uses: actions/upload-artifact@v2
-        with:
-          path: ./release
-          name: binaries
-          retention-days: 1
-  notarize:
-    name: Notarize Darwin binaries
-    runs-on: macos-11
-    needs: build
-    steps:
-      - name: Get tag name
-        id: tag_name
-        run: |
-          tag=${GITHUB_TAG/refs\/tags\//}
-          echo ::set-output name=tag::$tag
-        env:
-          GITHUB_TAG: ${{ github.ref }}
-      - name: Download binaries
-        uses: actions/download-artifact@v2
-        with:
-          name: binaries
-          path: release/
-      - name: Unzip Darwin binaries
-        run: |
-          unzip ./release/darwin/UNSIGNED_porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
-          unzip ./release/darwin/UNSIGNED_portersvr_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
-          unzip ./release/darwin/UNSIGNED_docker-credential-porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
-      - name: Import Code-Signing Certificates
-        uses: Apple-Actions/import-codesign-certs@v1
+      - name: Configure AWS credentials
+        uses: aws-actions/configure-aws-credentials@v1
         with:
-          # The certificates in a PKCS12 file encoded as a base64 string
-          p12-file-base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}
-          # The password used to import the PKCS12 file.
-          p12-password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
-      - name: Install gon via HomeBrew for code signing and app notarization
-        run: |
-          brew tap mitchellh/gon
-          brew install mitchellh/gon/gon
-      - name: Create a porter.gon.json file
-        run: |
-          echo "
-          {
-              \"source\": [\"./porter\"],
-              \"bundle_id\": \"cli.porter\",
-              \"apple_id\": {
-                  \"password\":  \"@env:AC_PASSWORD\"
-              },
-              \"sign\": {
-                  \"application_identity\": \"${{ secrets.AC_APPLICATION_IDENTITY }}\"
-              },
-              \"zip\": {
-                  \"output_path\": \"./release/darwin/porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip\"
-              }
-          }
-          " > ./porter.gon.json
-      - name: Create a portersvr.gon.json file
-        run: |
-          echo "
-          {
-              \"source\": [\"./portersvr\"],
-              \"bundle_id\": \"cli.portersvr\",
-              \"apple_id\": {
-                  \"password\":  \"@env:AC_PASSWORD\"
-              },
-              \"sign\": {
-                  \"application_identity\": \"${{ secrets.AC_APPLICATION_IDENTITY }}\"
-              },
-              \"zip\": {
-                  \"output_path\": \"./release/darwin/portersvr_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip\"
-              }
-          }
-          " > ./portersvr.gon.json
-      - name: Create a docker-credential-porter.gon.json file
-        run: |
-          echo "
-          {
-              \"source\": [\"./docker-credential-porter\"],
-              \"bundle_id\": \"cli.docker-credential-porter\",
-              \"apple_id\": {
-                  \"password\":  \"@env:AC_PASSWORD\"
-              },
-              \"sign\": {
-                  \"application_identity\": \"${{ secrets.AC_APPLICATION_IDENTITY }}\"
-              },
-              \"zip\": {
-                  \"output_path\": \"./release/darwin/docker-credential-porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip\"
-              }
-          }
-          " > ./docker-credential-porter.gon.json
-      - name: Sign the mac binaries with Gon
-        env:
-          AC_USERNAME: ${{ secrets.AC_USERNAME }}
-          AC_PASSWORD: ${{ secrets.AC_PASSWORD }}
+          aws-access-key-id: ${{ secrets.ECR_AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.ECR_AWS_SECRET_ACCESS_KEY }}
+          aws-region: us-east-2
+      - name: Login to ECR public
+        id: login-ecr
         run: |
-          gon ./porter.gon.json &
-          gon ./portersvr.gon.json &
-          gon ./docker-credential-porter.gon.json &
-          wait
-      - name: Upload binaries
-        uses: actions/upload-artifact@v2
-        with:
-          path: ./release
-          name: binaries
-          retention-days: 1
-  release:
-    name: Zip binaries, create release and upload assets
-    runs-on: ubuntu-latest
-    needs: notarize
-    steps:
-      - name: Get tag name
-        id: tag_name
+          aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/o1j4x7p4
+      - name: Pull versioned CLI image and push to latest
         run: |
-          tag=${GITHUB_TAG/refs\/tags\//}
-          echo ::set-output name=tag::$tag
-        env:
-          GITHUB_TAG: ${{ github.ref }}
-      - name: Download binaries
-        uses: actions/download-artifact@v2
-        with:
-          name: binaries
-          path: release/
-      - name: Create Release
-        id: create_release
-        uses: actions/create-release@v1
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-        with:
-          tag_name: ${{ github.ref }}
-          release_name: Release ${{ github.ref }}
-          draft: false
-          prerelease: true
-      - name: Upload Linux CLI Release Asset
-        id: upload-linux-cli-release-asset
-        uses: actions/upload-release-asset@v1
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          GITHUB_TAG: ${{ github.ref }}
-        with:
-          upload_url: ${{ steps.create_release.outputs.upload_url }}
-          asset_path: ./release/linux/porter_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip
-          asset_name: porter_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip
-          asset_content_type: application/zip
-      - name: Upload Linux Server Release Asset
-        id: upload-linux-server-release-asset
-        uses: actions/upload-release-asset@v1
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          GITHUB_TAG: ${{ github.ref }}
-        with:
-          upload_url: ${{ steps.create_release.outputs.upload_url }}
-          asset_path: ./release/linux/portersvr_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip
-          asset_name: portersvr_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip
-          asset_content_type: application/zip
-      - name: Upload Linux Docker Credential Release Asset
-        id: upload-linux-docker-cred-release-asset
-        uses: actions/upload-release-asset@v1
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          GITHUB_TAG: ${{ github.ref }}
-        with:
-          upload_url: ${{ steps.create_release.outputs.upload_url }}
-          asset_path: ./release/linux/docker-credential-porter_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip
-          asset_name: docker-credential-porter_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip
-          asset_content_type: application/zip
-      - name: Upload Darwin CLI Release Asset
-        id: upload-darwin-cli-release-asset
-        uses: actions/upload-release-asset@v1
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          GITHUB_TAG: ${{ github.ref }}
-        with:
-          upload_url: ${{ steps.create_release.outputs.upload_url }}
-          asset_path: ./release/darwin/porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
-          asset_name: porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
-          asset_content_type: application/zip
-      - name: Upload Darwin Server Release Asset
-        id: upload-darwin-server-release-asset
-        uses: actions/upload-release-asset@v1
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          GITHUB_TAG: ${{ github.ref }}
-        with:
-          upload_url: ${{ steps.create_release.outputs.upload_url }}
-          asset_path: ./release/darwin/portersvr_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
-          asset_name: portersvr_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
-          asset_content_type: application/zip
-      - name: Upload Darwin Docker Credential Release Asset
-        id: upload-darwin-docker-cred-release-asset
-        uses: actions/upload-release-asset@v1
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          GITHUB_TAG: ${{ github.ref }}
-        with:
-          upload_url: ${{ steps.create_release.outputs.upload_url }}
-          asset_path: ./release/darwin/docker-credential-porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
-          asset_name: docker-credential-porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
-          asset_content_type: application/zip
-      - name: Upload Windows CLI Release Asset
-        id: upload-windows-cli-release-asset
-        uses: actions/upload-release-asset@v1
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          GITHUB_TAG: ${{ github.ref }}
-        with:
-          upload_url: ${{ steps.create_release.outputs.upload_url }}
-          asset_path: ./release/windows/porter_${{steps.tag_name.outputs.tag}}_Windows_x86_64.zip
-          asset_name: porter_${{steps.tag_name.outputs.tag}}_Windows_x86_64.zip
-          asset_content_type: application/zip
-      - name: Upload Windows Server Release Asset
-        id: upload-windows-server-release-asset
-        uses: actions/upload-release-asset@v1
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          GITHUB_TAG: ${{ github.ref }}
-        with:
-          upload_url: ${{ steps.create_release.outputs.upload_url }}
-          asset_path: ./release/windows/portersvr_${{steps.tag_name.outputs.tag}}_Windows_x86_64.zip
-          asset_name: portersvr_${{steps.tag_name.outputs.tag}}_Windows_x86_64.zip
-          asset_content_type: application/zip
-      - name: Upload Windows Docker Credential Release Asset
-        id: upload-windows-docker-cred-release-asset
-        uses: actions/upload-release-asset@v1
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          GITHUB_TAG: ${{ github.ref }}
-        with:
-          upload_url: ${{ steps.create_release.outputs.upload_url }}
-          asset_path: ./release/windows/docker-credential-porter_${{steps.tag_name.outputs.tag}}_Windows_x86_64.zip
-          asset_name: docker-credential-porter_${{steps.tag_name.outputs.tag}}_Windows_x86_64.zip
-          asset_content_type: application/zip
-      - name: Upload Static Release Asset
-        id: upload-static-release-asset
-        uses: actions/upload-release-asset@v1
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          GITHUB_TAG: ${{ github.ref }}
-        with:
-          upload_url: ${{ steps.create_release.outputs.upload_url }}
-          asset_path: ./release/static/static_${{steps.tag_name.outputs.tag}}.zip
-          asset_name: static_${{steps.tag_name.outputs.tag}}.zip
-          asset_content_type: application/zip
-  build-push-docker-cli:
-    name: Build a new porter-cli docker image
+          docker pull public.ecr.aws/o1j4x7p4/porter-cli:${{steps.tag_name.outputs.tag}}
+          docker tag public.ecr.aws/o1j4x7p4/porter-cli:${{steps.tag_name.outputs.tag}} public.ecr.aws/o1j4x7p4/porter-cli:latest
+          docker push public.ecr.aws/o1j4x7p4/porter-cli:latest
+  update-homebrew-repo:
+    name: Update the Homebrew repo with the new CLI version
     runs-on: ubuntu-latest
-    needs: release
     steps:
       - name: Get tag name
         id: tag_name
@@ -359,24 +60,39 @@ jobs:
           echo ::set-output name=tag::$tag
         env:
           GITHUB_TAG: ${{ github.ref }}
-      - name: Checkout
-        uses: actions/checkout@v2.3.4
-      - name: Configure AWS credentials
-        uses: aws-actions/configure-aws-credentials@v1
-        with:
-          aws-access-key-id: ${{ secrets.ECR_AWS_ACCESS_KEY_ID }}
-          aws-secret-access-key: ${{ secrets.ECR_AWS_SECRET_ACCESS_KEY }}
-          aws-region: us-east-2
-      - name: Login to ECR public
-        id: login-ecr
-        run: |
-          aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/o1j4x7p4
-      - name: Build
+      - name: Create and commit porter.rb file
         run: |
-          docker build ./services/porter_cli_container \
-            -t public.ecr.aws/o1j4x7p4/porter-cli:${{steps.tag_name.outputs.tag}} \
-            -f ./services/porter_cli_container/Dockerfile \
-            --build-arg VERSION=${{steps.tag_name.outputs.tag}}
-      - name: Push
+          version=${{steps.tag_name.outputs.tag}}
+          name=porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
+          curl -L https://github.com/porter-dev/porter/releases/download/${version}/porter_${version}_Darwin_x86_64.zip --output $name
+
+          sha=$(cat porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip | openssl sha256 | sed 's/(stdin)= //g')
+
+          cat >porter.rb <<EOL
+          class Porter < Formula
+            homepage "https://porter.run"
+            version "${{steps.tag_name.outputs.tag}}"
+          
+            on_macos do
+              url "https://github.com/porter-dev/porter/releases/download/${{steps.tag_name.outputs.tag}}/porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip"
+              sha256 "$sha"
+          
+              def install
+                bin.install "porter"
+              end
+            end
+          end
+          EOL
+      - name: Add and commit porter.rb file
         run: |
-          docker push public.ecr.aws/o1j4x7p4/porter-cli:${{steps.tag_name.outputs.tag}}
+          git clone https://abelanger5:${{ secrets.HOMEBREW_GITHUB_TOKEN }}@github.com/porter-dev/homebrew-porter
+
+          cd homebrew-porter
+          git config user.name "Update Bot"
+          git config user.email "support@porter.run"
+
+          mv ../porter.rb ./Formula/porter.rb
+
+          git add Formula
+          git commit -m "Update to version ${{steps.tag_name.outputs.tag}}"
+          git push origin main

+ 44 - 17
api/client/api.go

@@ -97,32 +97,59 @@ func (c *Client) getRequest(relPath string, data interface{}, response interface
 	return nil
 }
 
-func (c *Client) postRequest(relPath string, data interface{}, response interface{}) error {
-	strData, err := json.Marshal(data)
+type postRequestOpts struct {
+	retryCount uint
+}
 
-	if err != nil {
-		return nil
+func (c *Client) postRequest(relPath string, data interface{}, response interface{}, opts ...postRequestOpts) error {
+	var retryCount uint = 1
+
+	if len(opts) > 0 {
+		for _, opt := range opts {
+			retryCount = opt.retryCount
+		}
 	}
 
-	req, err := http.NewRequest(
-		"POST",
-		fmt.Sprintf("%s%s", c.BaseURL, relPath),
-		strings.NewReader(string(strData)),
-	)
+	var httpErr *types.ExternalError
+	var err error
 
-	if err != nil {
-		return err
-	}
+	for i := 0; i < int(retryCount); i++ {
+		strData, err := json.Marshal(data)
 
-	if httpErr, err := c.sendRequest(req, response, true); httpErr != nil || err != nil {
-		if httpErr != nil {
-			return fmt.Errorf("%v", httpErr.Error)
+		if err != nil {
+			return nil
 		}
 
-		return err
+		req, err := http.NewRequest(
+			"POST",
+			fmt.Sprintf("%s%s", c.BaseURL, relPath),
+			strings.NewReader(string(strData)),
+		)
+
+		if err != nil {
+			return err
+		}
+
+		httpErr, err = c.sendRequest(req, response, true)
+
+		if httpErr == nil && err == nil {
+			return nil
+		}
+
+		if i != int(retryCount)-1 {
+			if httpErr != nil {
+				fmt.Printf("Error: %s (status code %d), retrying request...\n", httpErr.Error, httpErr.Code)
+			} else {
+				fmt.Printf("Error: %v, retrying request...\n", err)
+			}
+		}
 	}
 
-	return nil
+	if httpErr != nil {
+		return fmt.Errorf("%v", httpErr.Error)
+	}
+
+	return err
 }
 
 func (c *Client) deleteRequest(relPath string, data interface{}, response interface{}) error {

+ 3 - 0
api/client/deploy.go

@@ -102,5 +102,8 @@ func (c *Client) UpgradeRelease(
 		),
 		req,
 		nil,
+		postRequestOpts{
+			retryCount: 3,
+		},
 	)
 }

+ 221 - 45
api/server/handlers/kube_events/create.go

@@ -14,7 +14,9 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/helm/grapher"
 	"github.com/porter-dev/porter/internal/integrations/slack"
+	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
 	"gorm.io/gorm"
 )
@@ -89,7 +91,14 @@ func (c *CreateKubeEventHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	w.WriteHeader(http.StatusCreated)
 
 	if strings.ToLower(string(request.EventType)) == "critical" && strings.ToLower(request.ResourceType) == "pod" {
-		err := notifyPodCrashing(c.Config(), proj, cluster, request)
+		agent, err := c.GetAgent(r, cluster, request.Namespace)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		err = notifyPodCrashing(c.Config(), agent, proj, cluster, request)
 
 		if err != nil {
 			c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
@@ -99,6 +108,7 @@ func (c *CreateKubeEventHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 
 func notifyPodCrashing(
 	config *config.Config,
+	agent *kubernetes.Agent,
 	project *models.Project,
 	cluster *models.Cluster,
 	event *types.CreateKubeEventRequest,
@@ -106,72 +116,136 @@ func notifyPodCrashing(
 	// attempt to get a matching Porter release to get the notification configuration
 	var conf *models.NotificationConfig
 	var notifConfig *types.NotificationConfig
+	var notifyOpts *slack.NotifyOpts
+	var matchedRel *models.Release
 	var err error
-	matchedRel := getMatchedPorterRelease(config, cluster.ID, event.OwnerName, event.Namespace)
 
-	// for now, we only notify for Porter releases that have been deployed through Porter
-	if matchedRel == nil {
-		return nil
-	}
+	if isJob := strings.ToLower(event.OwnerType) == "job"; isJob {
+		// check that the job alert is valid and get proper message
+		jobOwner, jobMsg, jobName, shouldAlert, err := getJobAlert(agent, event.Name, event.Namespace)
 
-	conf, err = config.Repo.NotificationConfig().ReadNotificationConfig(matchedRel.NotificationConfig)
-
-	if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
-		conf = &models.NotificationConfig{
-			Enabled: true,
-			Success: true,
-			Failure: true,
+		if err != nil {
+			return err
+		} else if !shouldAlert {
+			return nil
 		}
 
-		conf, err = config.Repo.NotificationConfig().CreateNotificationConfig(conf)
+		// look for a matching job notification config
+		jobNC, err := config.Repo.JobNotificationConfig().ReadNotificationConfig(project.ID, cluster.ID, jobName, event.Namespace)
 
-		if err != nil {
+		if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
 			return err
 		}
 
-		if err != nil {
+		if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+			// if the job notification config does not exist, create it
+			jobNC = &models.JobNotificationConfig{
+				Name:             jobName,
+				Namespace:        event.Namespace,
+				ProjectID:        project.ID,
+				ClusterID:        cluster.ID,
+				LastNotifiedTime: time.Now(),
+			}
+
+			jobNC, err = config.Repo.JobNotificationConfig().CreateNotificationConfig(jobNC)
+
+			if err != nil {
+				return err
+			}
+		} else if err != nil {
 			return err
+		} else if err == nil && jobNC != nil {
+			// If the job notification config does exist, check if the job notification config states that
+			// a notification should happen. If so, notify.
+			if !jobNC.ShouldNotify() {
+				return nil
+			}
 		}
 
-		matchedRel.NotificationConfig = conf.ID
-		matchedRel, err = config.Repo.Release().UpdateRelease(matchedRel)
-
-		if err != nil {
-			return err
+		notifyOpts = &slack.NotifyOpts{
+			ProjectID:   cluster.ProjectID,
+			ClusterID:   cluster.ID,
+			ClusterName: cluster.Name,
+			Name:        jobOwner,
+			Namespace:   event.Namespace,
+			Info:        fmt.Sprintf("%s", jobMsg),
+			Timestamp:   &event.Timestamp,
+			URL: fmt.Sprintf(
+				"%s/jobs/%s/%s/%s?project_id=%d&job=%s",
+				config.ServerConf.ServerURL,
+				cluster.Name,
+				event.Namespace,
+				jobOwner,
+				cluster.ProjectID,
+				jobName,
+			),
 		}
+	} else {
+		matchedRel := getMatchedPorterRelease(config, cluster.ID, event.OwnerName, event.Namespace)
 
-		notifConfig = conf.ToNotificationConfigType()
-	} else if err != nil {
-		return err
-	} else if err == nil && conf != nil {
-		if !conf.ShouldNotify() {
+		// for now, we only notify for Porter releases that have been deployed through Porter
+		if matchedRel == nil {
 			return nil
 		}
 
-		notifConfig = conf.ToNotificationConfigType()
-	}
+		conf, err = config.Repo.NotificationConfig().ReadNotificationConfig(matchedRel.NotificationConfig)
 
-	slackInts, _ := config.Repo.SlackIntegration().ListSlackIntegrationsByProjectID(project.ID)
+		if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+			conf = &models.NotificationConfig{
+				Enabled: true,
+				Success: true,
+				Failure: true,
+			}
 
-	notifier := slack.NewSlackNotifier(notifConfig, slackInts...)
+			conf, err = config.Repo.NotificationConfig().CreateNotificationConfig(conf)
+
+			if err != nil {
+				return err
+			}
+
+			if err != nil {
+				return err
+			}
+
+			matchedRel.NotificationConfig = conf.ID
+			matchedRel, err = config.Repo.Release().UpdateRelease(matchedRel)
+
+			if err != nil {
+				return err
+			}
+
+			notifConfig = conf.ToNotificationConfigType()
+		} else if err != nil {
+			return err
+		} else if err == nil && conf != nil {
+			if !conf.ShouldNotify() {
+				return nil
+			}
+
+			notifConfig = conf.ToNotificationConfigType()
+		}
 
-	notifyOpts := &slack.NotifyOpts{
-		ProjectID:   cluster.ProjectID,
-		ClusterID:   cluster.ID,
-		ClusterName: cluster.Name,
-		Name:        event.OwnerName,
-		Namespace:   event.Namespace,
-		Info:        fmt.Sprintf("%s:%s", event.Reason, event.Message),
-		URL: fmt.Sprintf(
-			"%s/applications/%s/%s/%s?project_id=%d",
-			config.ServerConf.ServerURL,
-			url.PathEscape(cluster.Name),
-			matchedRel.Namespace,
-			matchedRel.Name,
-			cluster.ProjectID,
-		),
+		notifyOpts = &slack.NotifyOpts{
+			ProjectID:   cluster.ProjectID,
+			ClusterID:   cluster.ID,
+			ClusterName: cluster.Name,
+			Name:        event.OwnerName,
+			Namespace:   event.Namespace,
+			Info:        fmt.Sprintf("%s:%s", event.Reason, event.Message),
+			URL: fmt.Sprintf(
+				"%s/applications/%s/%s/%s?project_id=%d",
+				config.ServerConf.ServerURL,
+				url.PathEscape(cluster.Name),
+				matchedRel.Namespace,
+				matchedRel.Name,
+				cluster.ProjectID,
+			),
+		}
 	}
 
+	slackInts, _ := config.Repo.SlackIntegration().ListSlackIntegrationsByProjectID(project.ID)
+
+	notifier := slack.NewSlackNotifier(notifConfig, slackInts...)
 	notifyOpts.Status = slack.StatusPodCrashed
 
 	err = notifier.Notify(notifyOpts)
@@ -211,3 +285,105 @@ func getMatchedPorterRelease(config *config.Config, clusterID uint, ownerName, n
 
 	return rel
 }
+
+func getJobAlert(agent *kubernetes.Agent, name, namespace string) (
+	ownerName string,
+	msg string,
+	jobName string,
+	shouldAlert bool,
+	err error,
+) {
+	ownerName = ""
+
+	pod, err := agent.GetPodByName(name, namespace)
+
+	// if the pod is not found, we should not alert for this pod
+	if err != nil && errors.Is(err, kubernetes.IsNotFoundError) {
+		return "", "", "", false, nil
+	} else if err != nil {
+		return "", "", "", false, err
+	}
+
+	ownerJobName := ""
+
+	// get the owner name for the pod by looking at the owner reference
+	if ownerRefArr := pod.ObjectMeta.OwnerReferences; len(ownerRefArr) > 0 {
+		for _, ownerRef := range ownerRefArr {
+			if strings.ToLower(ownerRef.Kind) == "job" {
+				ownerJobName = ownerRef.Name
+			}
+		}
+	}
+
+	if ownerJobName == "" {
+		return "", "", "", false, nil
+	}
+
+	// lookup the job in the cluster
+	job, err := agent.GetJob(grapher.Object{
+		Kind:      "Job",
+		Name:      ownerJobName,
+		Namespace: namespace,
+	})
+
+	if err != nil {
+		return "", "", "", false, nil
+	}
+
+	if jobReleaseLabel, exists := job.ObjectMeta.Labels["meta.helm.sh/release-name"]; exists {
+		ownerName = jobReleaseLabel
+	}
+
+	// if we don't have an owner name, don't alert -- the link will be broken
+	if ownerName == "" {
+		return "", "", "", false, nil
+	}
+
+	// only alert for jobs that are newer than 24 hours
+	if podTime := pod.Status.StartTime; podTime != nil && podTime.After(time.Now().Add(-24*time.Hour)) {
+		// find container statuses relating to the actual job container. We don't alert on sidecar containers
+		for _, containerStatus := range pod.Status.ContainerStatuses {
+			if containerStatus.Name != "sidecar" && containerStatus.Name != "cloud-sql-proxy" {
+				state := containerStatus.State
+				if state.Terminated != nil && state.Terminated.ExitCode != 0 {
+					// before alerting, we check pod events to make sure the pod was not moved due to normal behavior such as scale down
+					events, err := agent.ListEvents(name, namespace)
+
+					if err == nil && len(events.Items) > 0 {
+						for _, event := range events.Items {
+							// if event is ScaleDown, don't alert
+							if event.Reason == "ScaleDown" && strings.Contains(event.Message, "deleting pod for node scale down") {
+								return ownerName, "", ownerJobName, false, nil
+							}
+						}
+					}
+
+					// next, if the exit code is 255, we check that the job doesn't have a different associated pod.
+					// exit code 255 can mean this pod was moved to a different node due to node eviction, scaledown,
+					// unhealthy node, etc
+					if state.Terminated.ExitCode == 255 {
+						jobPods, err := agent.GetJobPods(namespace, ownerJobName)
+
+						if err == nil && len(jobPods) > 0 {
+							for _, jobPod := range jobPods {
+								if jobPod.ObjectMeta.Name != name {
+									return ownerName, "", ownerJobName, false, nil
+								}
+							}
+						}
+					}
+
+					msg := fmt.Sprintf("Job terminated with non-zero exit code: exit code %d.", state.Terminated.ExitCode)
+
+					if state.Terminated.Message != "" {
+						msg += fmt.Sprintf(" Error: %s", state.Terminated.Message)
+					}
+
+					return ownerName, msg, ownerJobName, true, nil
+				}
+			}
+		}
+	}
+
+	return "", "", "", false, nil
+}

+ 7 - 1
api/server/handlers/namespace/stream_pod_logs.go

@@ -34,6 +34,12 @@ func NewStreamPodLogsHandler(
 }
 
 func (c *StreamPodLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.GetPodLogsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
 	safeRW := r.Context().Value(types.RequestCtxWebsocketKey).(*websocket.WebsocketSafeReadWriter)
 	namespace := r.Context().Value(types.NamespaceScope).(string)
 	name, _ := requestutils.GetURLParamString(r, types.URLParamPodName)
@@ -47,7 +53,7 @@ func (c *StreamPodLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	err = agent.GetPodLogs(namespace, name, safeRW)
+	err = agent.GetPodLogs(namespace, name, request.Previous, request.Container, safeRW)
 
 	if targetErr := kubernetes.IsNotFoundError; errors.Is(err, targetErr) {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(

+ 1 - 0
api/server/handlers/release/create.go

@@ -247,6 +247,7 @@ func createGitAction(
 
 	// create the commit in the git repo
 	gaRunner := &actions.GithubActions{
+		InstanceName:           config.ServerConf.InstanceName,
 		ServerURL:              config.ServerConf.ServerURL,
 		GithubOAuthIntegration: nil,
 		GithubAppID:            config.GithubAppConf.AppID,

+ 5 - 3
api/server/handlers/release/ugprade.go

@@ -167,10 +167,12 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 	}
 
-	notifyOpts.Status = slack.StatusHelmDeployed
-	notifyOpts.Version = helmRelease.Version
+	if helmRelease.Chart != nil && helmRelease.Chart.Metadata.Name != "job" {
+		notifyOpts.Status = slack.StatusHelmDeployed
+		notifyOpts.Version = helmRelease.Version
 
-	notifier.Notify(notifyOpts)
+		notifier.Notify(notifyOpts)
+	}
 
 	// update the github actions env if the release exists and is built from source
 	if cName := helmRelease.Chart.Metadata.Name; cName == "job" || cName == "web" || cName == "worker" {

+ 5 - 3
api/server/handlers/release/upgrade_webhook.go

@@ -182,10 +182,12 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	notifyOpts.Status = slack.StatusHelmDeployed
-	notifyOpts.Version = rel.Version
+	if rel.Chart != nil && rel.Chart.Metadata.Name != "job" {
+		notifyOpts.Status = slack.StatusHelmDeployed
+		notifyOpts.Version = rel.Version
 
-	notifier.Notify(notifyOpts)
+		notifier.Notify(notifyOpts)
+	}
 
 	c.Config().AnalyticsClient.Track(analytics.ApplicationDeploymentWebhookTrack(&analytics.ApplicationDeploymentWebhookTrackOpts{
 		ImageURI: fmt.Sprintf("%v", repository),

+ 52 - 0
api/server/handlers/user/can_create_project.go

@@ -0,0 +1,52 @@
+package user
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type CanCreateProject struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewCanCreateProjectHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CanCreateProject {
+	return &CanCreateProject{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *CanCreateProject) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	if c.Config().ServerConf.DisableAllowlist {
+		c.WriteResult(w, r, "")
+		return
+	}
+
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	exists, err := c.Repo().Allowlist().UserEmailExists(user.Email)
+
+	if err != nil {
+		err = fmt.Errorf("couldn't retrieve user: %s", err.Error())
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if !exists {
+		err = fmt.Errorf("user is not authorized")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, 403))
+		return
+	}
+
+	c.WriteResult(w, r, "")
+}

+ 25 - 0
api/server/router/user.go

@@ -421,5 +421,30 @@ func getUserRoutes(
 		Router:   r,
 	})
 
+	// GET /api/can_create_project -> user.CanCreateProject
+	canCreateProjectEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/can_create_project",
+			},
+			Scopes: []types.PermissionScope{types.UserScope},
+		},
+	)
+
+	canCreateProjectHandler := user.NewCanCreateProjectHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: canCreateProjectEndpoint,
+		Handler:  canCreateProjectHandler,
+		Router:   r,
+	})
+
 	return routes
 }

+ 5 - 3
api/server/shared/apierrors/errors.go

@@ -3,6 +3,7 @@ package apierrors
 import (
 	"encoding/json"
 	"net/http"
+	"strings"
 
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
@@ -68,10 +69,11 @@ func (e *ErrForbidden) GetStatusCode() int {
 type ErrPassThroughToClient struct {
 	err        error
 	statusCode int
+	errDetails []string
 }
 
-func NewErrPassThroughToClient(err error, statusCode int) RequestError {
-	return &ErrPassThroughToClient{err, statusCode}
+func NewErrPassThroughToClient(err error, statusCode int, details ...string) RequestError {
+	return &ErrPassThroughToClient{err, statusCode, details}
 }
 
 func (e *ErrPassThroughToClient) Error() string {
@@ -79,7 +81,7 @@ func (e *ErrPassThroughToClient) Error() string {
 }
 
 func (e *ErrPassThroughToClient) InternalError() string {
-	return e.err.Error()
+	return e.err.Error() + strings.Join(e.errDetails, ",")
 }
 
 func (e *ErrPassThroughToClient) ExternalError() string {

+ 11 - 1
api/server/shared/config/env/envconfs.go

@@ -6,7 +6,14 @@ import "time"
 type ServerConf struct {
 	Debug bool `env:"DEBUG,default=false"`
 
-	ServerURL            string        `env:"SERVER_URL,default=http://localhost:8080"`
+	ServerURL string `env:"SERVER_URL,default=http://localhost:8080"`
+
+	// The instance name is used to set a name for integrations linked only by a project ID,
+	// in order to differentiate between the same project ID on different instances. For example,
+	// when writing a Github secret with `PORTER_TOKEN_<PROJECT_ID>`, setting this value will change
+	// this to `PORTER_TOKEN_<INSTANCE_NAME>_<PROJECT_ID>`
+	InstanceName string `env:"INSTANCE_NAME"`
+
 	Port                 int           `env:"SERVER_PORT,default=8080"`
 	StaticFilePath       string        `env:"STATIC_FILE_PATH,default=/porter/static"`
 	CookieName           string        `env:"COOKIE_NAME,default=porter"`
@@ -88,6 +95,9 @@ type ServerConf struct {
 
 	// Enable pprof profiling endpoints
 	PprofEnabled bool `env:"PPROF_ENABLED,default=false"`
+
+	// Disable filtering for project creation
+	DisableAllowlist bool `env:"DISABLE_ALLOWLIST,default=false"`
 }
 
 // DBConf is the database configuration: if generated from environment variables,

+ 1 - 1
api/server/shared/requestutils/decoder.go

@@ -70,7 +70,7 @@ func requestErrorFromJSONErr(err error) apierrors.RequestError {
 	} else if errors.As(err, &typeErr) {
 		clientErr = fmt.Errorf("Invalid type for body param %s: expected %s, got %s", typeErr.Field, typeErr.Type.Kind().String(), typeErr.Value)
 	} else {
-		clientErr = fmt.Errorf("Could not parse JSON request")
+		return apierrors.NewErrPassThroughToClient(fmt.Errorf("Could not parse JSON request"), http.StatusBadRequest, err.Error())
 	}
 
 	return apierrors.NewErrPassThroughToClient(clientErr, http.StatusBadRequest)

+ 5 - 0
api/types/namespace.go

@@ -111,3 +111,8 @@ type RenameConfigMapResponse struct {
 type DeleteConfigMapRequest struct {
 	Name string `schema:"name,required"`
 }
+
+type GetPodLogsRequest struct {
+	Container string `schema:"container_name"`
+	Previous  bool   `schema:"previous"`
+}

+ 4 - 0
cli/cmd/config.go

@@ -6,6 +6,7 @@ import (
 	"os"
 	"path/filepath"
 	"strconv"
+	"strings"
 
 	"github.com/fatih/color"
 	"github.com/spf13/cobra"
@@ -179,6 +180,9 @@ func (c *CLIConfig) SetDriver(driver string) error {
 }
 
 func (c *CLIConfig) SetHost(host string) error {
+	// a trailing / can lead to errors with the api server
+	host = strings.TrimRight(host, "/")
+
 	viper.Set("host", host)
 	color.New(color.FgGreen).Printf("Set the current host as %s\n", host)
 	err := viper.WriteConfig()

+ 19 - 0
cli/cmd/create.go

@@ -5,6 +5,7 @@ import (
 	"io/ioutil"
 	"os"
 	"path/filepath"
+	"strings"
 
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
@@ -119,6 +120,14 @@ func init() {
 		"the path to the dockerfile",
 	)
 
+	createCmd.PersistentFlags().StringArrayVarP(
+		&buildFlagsEnv,
+		"env",
+		"e",
+		[]string{},
+		"Build-time environment variable, in the form 'VAR=VALUE'. These are not available at image runtime.",
+	)
+
 	createCmd.PersistentFlags().StringVar(
 		&method,
 		"method",
@@ -180,6 +189,15 @@ func createFull(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 		buildMethod = deploy.DeployBuildTypeDocker
 	}
 
+	// add additional env, if they exist
+	additionalEnv := make(map[string]string)
+
+	for _, buildEnv := range buildFlagsEnv {
+		if strSplArr := strings.SplitN(buildEnv, "=", 2); len(strSplArr) >= 2 {
+			additionalEnv[strSplArr[0]] = strSplArr[1]
+		}
+	}
+
 	createAgent := &deploy.CreateAgent{
 		Client: client,
 		CreateOpts: &deploy.CreateOpts{
@@ -190,6 +208,7 @@ func createFull(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 				LocalPath:       fullPath,
 				LocalDockerfile: dockerfile,
 				Method:          buildMethod,
+				AdditionalEnv:   additionalEnv,
 			},
 			Kind:        args[0],
 			ReleaseName: name,

+ 22 - 0
cli/cmd/deploy.go

@@ -3,6 +3,7 @@ package cmd
 import (
 	"fmt"
 	"os"
+	"strings"
 
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
@@ -204,8 +205,11 @@ var tag string
 var dockerfile string
 var method string
 var stream bool
+var buildFlagsEnv []string
 
 func init() {
+	buildFlagsEnv = []string{}
+
 	rootCmd.AddCommand(updateCmd)
 
 	updateCmd.PersistentFlags().StringVar(
@@ -262,6 +266,14 @@ func init() {
 		"the path to the dockerfile",
 	)
 
+	updateCmd.PersistentFlags().StringArrayVarP(
+		&buildFlagsEnv,
+		"env",
+		"e",
+		[]string{},
+		"Build-time environment variable, in the form 'VAR=VALUE'. These are not available at image runtime.",
+	)
+
 	updateCmd.PersistentFlags().StringVar(
 		&method,
 		"method",
@@ -384,6 +396,15 @@ func updateGetAgent(client *api.Client) (*deploy.DeployAgent, error) {
 		buildMethod = deploy.DeployBuildType(method)
 	}
 
+	// add additional env, if they exist
+	additionalEnv := make(map[string]string)
+
+	for _, buildEnv := range buildFlagsEnv {
+		if strSplArr := strings.SplitN(buildEnv, "=", 2); len(strSplArr) >= 2 {
+			additionalEnv[strSplArr[0]] = strSplArr[1]
+		}
+	}
+
 	// initialize the update agent
 	return deploy.NewDeployAgent(client, app, &deploy.DeployOpts{
 		SharedOpts: &deploy.SharedOpts{
@@ -394,6 +415,7 @@ func updateGetAgent(client *api.Client) (*deploy.DeployAgent, error) {
 			LocalDockerfile: dockerfile,
 			OverrideTag:     tag,
 			Method:          buildMethod,
+			AdditionalEnv:   additionalEnv,
 		},
 		Local: source != "github",
 	})

+ 5 - 0
cli/cmd/deploy/create.go

@@ -277,6 +277,11 @@ func (c *CreateAgent) CreateFromDocker(
 		env = map[string]string{}
 	}
 
+	// add additional env based on options
+	for key, val := range opts.SharedOpts.AdditionalEnv {
+		env[key] = val
+	}
+
 	buildAgent := &BuildAgent{
 		SharedOpts:  opts.SharedOpts,
 		client:      c.Client,

+ 12 - 1
cli/cmd/deploy/deploy.go

@@ -155,7 +155,18 @@ func (d *DeployAgent) GetBuildEnv(opts *GetBuildEnvOpts) (map[string]string, err
 		}
 	}
 
-	return GetEnvFromConfig(conf)
+	env, err := GetEnvFromConfig(conf)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// add additional env based on options
+	for key, val := range d.opts.SharedOpts.AdditionalEnv {
+		env[key] = val
+	}
+
+	return env, nil
 }
 
 // SetBuildEnv sets the build env vars in the process so that other commands can

+ 1 - 0
cli/cmd/deploy/shared.go

@@ -9,4 +9,5 @@ type SharedOpts struct {
 	LocalDockerfile string
 	OverrideTag     string
 	Method          DeployBuildType
+	AdditionalEnv   map[string]string
 }

+ 4 - 0
cli/cmd/docker/builder.go

@@ -62,6 +62,10 @@ func (a *Agent) BuildLocal(opts *BuildOpts) error {
 		buildArgs[key] = &valCopy
 	}
 
+	// attach BUILDKIT_INLINE_CACHE=1 by default, to take advantage of caching
+	inlineCacheVal := "1"
+	buildArgs["BUILDKIT_INLINE_CACHE"] = &inlineCacheVal
+
 	out, err := a.client.ImageBuild(context.Background(), tar, types.ImageBuildOptions{
 		Dockerfile: dockerfilePath,
 		BuildArgs:  buildArgs,

+ 86 - 6
cli/cmd/pack/pack.go

@@ -3,20 +3,23 @@ package pack
 import (
 	"context"
 	"fmt"
+	"io/ioutil"
+	"net/url"
 	"path/filepath"
+	"regexp"
 	"strings"
 
 	"github.com/buildpacks/pack"
+	githubApi "github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/docker"
+	"github.com/porter-dev/porter/cli/cmd/github"
+	"k8s.io/client-go/util/homedir"
 )
 
 type Agent struct{}
 
 func (a *Agent) Build(opts *docker.BuildOpts, buildConfig *types.BuildConfig) error {
-	//create a context object
-	context := context.Background()
-
 	//initialize a pack client
 	client, err := pack.NewClient(pack.WithLogger(newPackLogger()))
 
@@ -41,8 +44,85 @@ func (a *Agent) Build(opts *docker.BuildOpts, buildConfig *types.BuildConfig) er
 
 	if buildConfig != nil {
 		buildOpts.Builder = buildConfig.Builder
-		if len(buildConfig.Buildpacks) > 0 {
-			buildOpts.Buildpacks = buildConfig.Buildpacks
+		for i := range buildConfig.Buildpacks {
+			bp := buildConfig.Buildpacks[i]
+			if bp == "" {
+				continue
+			}
+			u, err := url.Parse(bp)
+			if err == nil && u.Scheme != "" {
+				// could be a git repository containing the buildpack
+				if !strings.HasSuffix(u.Path, ".zip") && u.Host != "github.com" && u.Host != "www.github.com" {
+					return fmt.Errorf("please provide either a github.com URL or a ZIP file URL")
+				}
+
+				urlPaths := strings.Split(u.Path[1:], "/")
+				dstDir := filepath.Join(homedir.HomeDir(), ".porter")
+				bpCustomName := regexp.MustCompile("/|-").ReplaceAllString(u.Path[1:], "_")
+
+				var zipFileName string
+				if strings.HasSuffix(bpCustomName, ".zip") {
+					zipFileName = bpCustomName
+				} else {
+					zipFileName = fmt.Sprintf("%s.zip", bpCustomName)
+				}
+				downloader := &github.ZIPDownloader{
+					ZipFolderDest:       dstDir,
+					AssetFolderDest:     dstDir,
+					ZipName:             zipFileName,
+					RemoveAfterDownload: true,
+				}
+
+				if zipFileName != bpCustomName {
+					// try to download the repo ZIP from github
+					githubClient := githubApi.NewClient(nil)
+					rel, _, err := githubClient.Repositories.GetLatestRelease(
+						context.Background(),
+						urlPaths[0],
+						urlPaths[1],
+					)
+					if err == nil {
+						bp = rel.GetZipballURL()
+					} else {
+						// default to the current default branch
+						repo, _, err := githubClient.Repositories.Get(
+							context.Background(),
+							urlPaths[0],
+							urlPaths[1],
+						)
+						if err != nil {
+							return fmt.Errorf("could not fetch git repo details")
+						}
+						bp = fmt.Sprintf("%s/archive/refs/heads/%s.zip", bp, repo.GetDefaultBranch())
+					}
+				}
+
+				err = downloader.DownloadToFile(bp)
+				if err != nil {
+					return err
+				}
+
+				err = downloader.UnzipToDir()
+				if err != nil {
+					return err
+				}
+
+				dstFiles, err := ioutil.ReadDir(dstDir)
+				if err != nil {
+					return err
+				}
+
+				var bpRealName string
+				for _, info := range dstFiles {
+					if info.Mode().IsDir() && strings.Contains(info.Name(), urlPaths[1]) {
+						bpRealName = filepath.Join(dstDir, info.Name())
+					}
+				}
+
+				buildOpts.Buildpacks = append(buildOpts.Buildpacks, bpRealName)
+			} else {
+				buildOpts.Buildpacks = append(buildOpts.Buildpacks, bp)
+			}
 		}
 		// FIXME: use all the config vars
 	}
@@ -51,5 +131,5 @@ func (a *Agent) Build(opts *docker.BuildOpts, buildConfig *types.BuildConfig) er
 		buildOpts.Buildpacks = append(buildOpts.Buildpacks, "heroku/procfile")
 	}
 
-	return client.Build(context, buildOpts)
+	return client.Build(context.Background(), buildOpts)
 }

+ 1 - 1
dashboard/babel.config.json

@@ -1,5 +1,5 @@
 {
-  "plugins": ["lodash"],
+  "plugins": ["lodash", "babel-plugin-styled-components"],
   "presets": [
     "@babel/preset-env",
     "@babel/preset-react",

+ 24 - 0
dashboard/package-lock.json

@@ -2933,6 +2933,30 @@
         "@babel/helper-module-imports": "^7.15.4",
         "babel-plugin-syntax-jsx": "^6.18.0",
         "lodash": "^4.17.11"
+      },
+      "dependencies": {
+        "@babel/helper-module-imports": {
+          "version": "7.16.0",
+          "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.0.tgz",
+          "integrity": "sha512-kkH7sWzKPq0xt3H1n+ghb4xEMP8k0U7XV3kkB+ZGy69kDk2ySFW1qPi06sjKzFY3t1j6XbJSqr4mF9L7CYVyhg==",
+          "requires": {
+            "@babel/types": "^7.16.0"
+          }
+        },
+        "@babel/helper-validator-identifier": {
+          "version": "7.15.7",
+          "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz",
+          "integrity": "sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w=="
+        },
+        "@babel/types": {
+          "version": "7.16.0",
+          "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.16.0.tgz",
+          "integrity": "sha512-PJgg/k3SdLsGb3hhisFvtLOw5ts113klrpLuIPtCJIU+BB24fqq6lf8RWqKJEjzqXR9AEH1rIb5XTqwBHB+kQg==",
+          "requires": {
+            "@babel/helper-validator-identifier": "^7.15.7",
+            "to-fast-properties": "^2.0.0"
+          }
+        }
       }
     },
     "babel-plugin-syntax-jsx": {

+ 1 - 0
dashboard/package.json

@@ -88,6 +88,7 @@
     "@types/webpack-dev-server": "^3.11.5",
     "babel-loader": "^8.2.2",
     "babel-plugin-lodash": "^3.3.4",
+    "babel-plugin-styled-components": "^1.13.3",
     "file-loader": "^6.1.0",
     "html-webpack-plugin": "^4.5.0",
     "prettier": "2.2.1",

+ 121 - 0
dashboard/src/components/DocsHelper.tsx

@@ -0,0 +1,121 @@
+import React, { Component, useState } from "react";
+import styled, { createGlobalStyle } from "styled-components";
+import Button from "@material-ui/core/Button";
+import Tooltip from "@material-ui/core/Tooltip";
+import { ClickAwayListener, TooltipProps } from "@material-ui/core";
+
+type Props = {
+  tooltipText: string;
+  link: string;
+};
+
+const DocsHelper: React.FC<Props> = ({ tooltipText, link }) => {
+  const [open, setOpen] = React.useState(false);
+
+  const handleTooltipClose = () => {
+    setOpen(false);
+  };
+
+  const handleTooltipOpen = () => {
+    setOpen(true);
+  };
+
+  const handleTooltipToggle = () => {
+    setOpen(!open);
+  };
+
+  return (
+    <DocsHelperContainer>
+      <ClickAwayListener
+        onClickAway={() => {
+          handleTooltipClose();
+        }}
+      >
+        <div>
+          <Tooltip
+            PopperProps={{
+              disablePortal: true,
+              placement: "top-end",
+            }}
+            onClose={handleTooltipClose}
+            open={open}
+            interactive
+            disableFocusListener
+            disableHoverListener
+            disableTouchListener
+            title={
+              <StyledContent onClick={handleTooltipOpen}>
+                {tooltipText}
+                <A target="_blank" href={link}>
+                  Documentation {">"}
+                </A>
+              </StyledContent>
+            }
+          >
+            <HelperButton onClick={handleTooltipToggle}>
+              <i className="material-icons">help_outline</i>
+            </HelperButton>
+          </Tooltip>
+        </div>
+      </ClickAwayListener>
+      <TooltipStyle />
+    </DocsHelperContainer>
+  );
+};
+
+export default DocsHelper;
+
+const StyledContent = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-size: 12px;
+  font-weight: normal;
+  padding: 12px 14px;
+  line-height: 1.5em;
+  user-select: text;
+  width: calc(100% + 14px);
+  height: calc(100% + 10px);
+  margin-left: -7px;
+  height: 100%;
+  background: #2e3135;
+  border: 1px solid #aaaabb;
+  border-radius: 5px;
+`;
+
+const HelperButton = styled.div`
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  margin-left: 10px;
+  justify-content: center;
+  > i {
+    color: #aaaabb;
+    width: 24px;
+    height: 24px;
+    font-size: 20px;
+    border-radius: 20px;
+  }
+`;
+
+const TooltipStyle = createGlobalStyle`
+  .MuiTooltip-tooltip {
+    background-color: #00000000 !important;
+    font-size: 12px !important;
+    padding: 0px;
+    max-width: 300px !important;    
+  }
+`;
+
+const A = styled.a`
+  display: inline-block;
+  height: 20px;
+  color: #8590ff;
+  text-decoration: underline;
+  cursor: pointer;
+  width: 100%;
+  text-align: right;
+  user-select: none;
+`;
+
+const DocsHelperContainer = styled.div`
+  margin-left: auto;
+`;

+ 2 - 2
dashboard/src/components/events/useLastSeenPodStatus.ts

@@ -21,7 +21,7 @@ const useLastSeenPodStatus = ({
       status?.phase === "Pending" &&
       status?.containerStatuses !== undefined
     ) {
-      return status.containerStatuses[0].state.waiting.reason;
+      return status.containerStatuses[0].state?.waiting?.reason || "Pending";
     } else if (status?.phase === "Pending") {
       return "Pending";
     }
@@ -36,7 +36,7 @@ const useLastSeenPodStatus = ({
       status?.containerStatuses?.forEach((s: any) => {
         if (s.state?.waiting) {
           collatedStatus =
-            s.state?.waiting.reason === "CrashLoopBackOff"
+            s.state?.waiting?.reason === "CrashLoopBackOff"
               ? "failed"
               : "waiting";
         } else if (s.state?.terminated) {

+ 2 - 295
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -1,10 +1,4 @@
-import React, {
-  Component,
-  useContext,
-  useEffect,
-  useMemo,
-  useState,
-} from "react";
+import React, { useContext, useEffect, useState } from "react";
 import styled, { keyframes } from "styled-components";
 
 import { integrationList } from "shared/common";
@@ -13,10 +7,8 @@ import api from "shared/api";
 import Loading from "components/Loading";
 import { ActionConfigType } from "../../shared/types";
 import InputRow from "../form-components/InputRow";
-import Selector from "components/Selector";
 import Heading from "components/form-components/Heading";
-import Helper from "components/form-components/Helper";
-import SelectRow from "components/form-components/SelectRow";
+import { BuildpackSelection } from "./BuildpackSelection";
 
 type PropsType = {
   actionConfig: ActionConfigType | null;
@@ -34,32 +26,13 @@ type PropsType = {
   setBuildConfig: (x: any) => void;
 };
 
-type Buildpack = {
-  name: string;
-  buildpack: string;
-  config: {
-    [key: string]: string;
-  };
-};
-
-type DetectedBuildpack = {
-  name: string;
-  builders: string[];
-  detected: Buildpack[];
-  others: Buildpack[];
-};
-
-type DetectBuildpackResponse = DetectedBuildpack[];
-
 const ActionDetails: React.FC<PropsType> = (props) => {
   const {
     actionConfig,
     branch,
     dockerfilePath,
     folderPath,
-    procfilePath,
     selectedRegistry,
-    setActionConfig,
     setDockerfilePath,
     setFolderPath,
     setProcfilePath,
@@ -227,272 +200,6 @@ const ActionDetails: React.FC<PropsType> = (props) => {
 
 export default ActionDetails;
 
-const DEFAULT_BUILDER_NAME = "heroku";
-const DEFAULT_PAKETO_STACK = "paketobuildpacks/builder:full";
-const DEFAULT_HEROKU_STACK = "heroku/buildpacks:20";
-
-type BuildConfig = {
-  builder: string;
-  buildpacks: string[];
-  config: null | {
-    [key: string]: string;
-  };
-};
-
-export const BuildpackSelection: React.FC<{
-  actionConfig: ActionConfigType;
-  folderPath: string;
-  branch: string;
-  hide: boolean;
-  onChange: (config: BuildConfig) => void;
-}> = ({ actionConfig, folderPath, branch, hide, onChange }) => {
-  const { currentProject } = useContext(Context);
-
-  const [builders, setBuilders] = useState<DetectedBuildpack[]>(null);
-  const [selectedBuilder, setSelectedBuilder] = useState<string>(null);
-
-  const [stacks, setStacks] = useState<string[]>(null);
-  const [selectedStack, setSelectedStack] = useState<string>(null);
-
-  const [selectedBuildpacks, setSelectedBuildpacks] = useState<Buildpack[]>([]);
-  const [availableBuildpacks, setAvailableBuildpacks] = useState<Buildpack[]>(
-    []
-  );
-
-  useEffect(() => {
-    let buildConfig: BuildConfig = {} as BuildConfig;
-
-    buildConfig.builder = selectedStack;
-    buildConfig.buildpacks = selectedBuildpacks?.map((buildpack) => {
-      return buildpack.buildpack;
-    });
-    if (typeof onChange === "function") {
-      onChange(buildConfig);
-    }
-  }, [selectedBuilder, selectedStack, selectedBuildpacks]);
-
-  useEffect(() => {
-    api
-      .detectBuildpack<DetectBuildpackResponse>(
-        "<token>",
-        {
-          dir: folderPath || ".",
-        },
-        {
-          project_id: currentProject.id,
-          git_repo_id: actionConfig.git_repo_id,
-          kind: "github",
-          owner: actionConfig.git_repo.split("/")[0],
-          name: actionConfig.git_repo.split("/")[1],
-          branch: branch,
-        }
-      )
-      // getMockData()
-      .then(({ data }) => {
-        const builders = data;
-
-        const defaultBuilder = builders.find(
-          (builder) => builder.name.toLowerCase() === DEFAULT_BUILDER_NAME
-        );
-
-        const detectedBuildpacks = defaultBuilder.detected;
-        const availableBuildpacks = defaultBuilder.others;
-        const defaultStack = defaultBuilder.builders.find((stack) => {
-          return (
-            stack === DEFAULT_HEROKU_STACK || stack === DEFAULT_PAKETO_STACK
-          );
-        });
-
-        setBuilders(builders);
-        setSelectedBuilder(defaultBuilder.name.toLowerCase());
-
-        setStacks(defaultBuilder.builders);
-        setSelectedStack(defaultStack);
-        if (!Array.isArray(detectedBuildpacks)) {
-          setSelectedBuildpacks([]);
-        } else {
-          setSelectedBuildpacks(detectedBuildpacks);
-        }
-        if (!Array.isArray(availableBuildpacks)) {
-          setAvailableBuildpacks([]);
-        } else {
-          setAvailableBuildpacks(availableBuildpacks);
-        }
-      })
-      .catch((err) => {
-        console.error(err);
-      });
-  }, [currentProject, actionConfig]);
-
-  const builderOptions = useMemo(() => {
-    if (!Array.isArray(builders)) {
-      return;
-    }
-
-    return builders.map((builder) => ({
-      label: builder.name,
-      value: builder.name.toLowerCase(),
-    }));
-  }, [builders]);
-
-  const stackOptions = useMemo(() => {
-    if (!Array.isArray(stacks)) {
-      return;
-    }
-
-    return stacks.map((stack) => ({
-      label: stack,
-      value: stack.toLowerCase(),
-    }));
-  }, [stacks]);
-
-  const handleSelectBuilder = (builderName: string) => {
-    const builder = builders.find(
-      (b) => b.name.toLowerCase() === builderName.toLowerCase()
-    );
-    const detectedBuildpacks = builder.detected;
-    const availableBuildpacks = builder.others;
-    const defaultStack = builder.builders.find((stack) => {
-      return stack === DEFAULT_HEROKU_STACK || stack === DEFAULT_PAKETO_STACK;
-    });
-    setSelectedBuilder(builderName);
-    setBuilders(builders);
-    setSelectedBuilder(builderName.toLowerCase());
-
-    setStacks(builder.builders);
-    setSelectedStack(defaultStack);
-
-    if (!Array.isArray(detectedBuildpacks)) {
-      setSelectedBuildpacks([]);
-    } else {
-      setSelectedBuildpacks(detectedBuildpacks);
-    }
-    if (!Array.isArray(availableBuildpacks)) {
-      setAvailableBuildpacks([]);
-    } else {
-      setAvailableBuildpacks(availableBuildpacks);
-    }
-  };
-
-  const renderBuildpacksList = (
-    buildpacks: Buildpack[],
-    action: "remove" | "add"
-  ) => {
-    return buildpacks?.map((buildpack) => {
-      const icon = `devicon-${buildpack?.name?.toLowerCase()}-plain colored`;
-
-      return (
-        <StyledCard>
-          <ContentContainer>
-            <Icon className={icon} />
-            <EventInformation>
-              <EventName>{buildpack?.name}</EventName>
-            </EventInformation>
-          </ContentContainer>
-          <ActionContainer>
-            {action === "add" && (
-              <DeleteButton
-                onClick={() => handleAddBuildpack(buildpack.buildpack)}
-              >
-                <span className="material-icons-outlined">add</span>
-              </DeleteButton>
-            )}
-            {action === "remove" && (
-              <DeleteButton
-                onClick={() => handleRemoveBuildpack(buildpack.buildpack)}
-              >
-                <span className="material-icons">delete</span>
-              </DeleteButton>
-            )}
-          </ActionContainer>
-        </StyledCard>
-      );
-    });
-  };
-
-  const handleRemoveBuildpack = (buildpackToRemove: string) => {
-    setSelectedBuildpacks((selBuildpacks) => {
-      const tmpSelectedBuildpacks = [...selBuildpacks];
-
-      const indexBuildpackToRemove = tmpSelectedBuildpacks.findIndex(
-        (buildpack) => buildpack.buildpack === buildpackToRemove
-      );
-      const buildpack = tmpSelectedBuildpacks[indexBuildpackToRemove];
-
-      setAvailableBuildpacks((availableBuildpacks) => [
-        ...availableBuildpacks,
-        buildpack,
-      ]);
-
-      tmpSelectedBuildpacks.splice(indexBuildpackToRemove, 1);
-
-      return [...tmpSelectedBuildpacks];
-    });
-  };
-
-  const handleAddBuildpack = (buildpackToAdd: string) => {
-    setAvailableBuildpacks((avBuildpacks) => {
-      const tmpAvailableBuildpacks = [...avBuildpacks];
-      const indexBuildpackToAdd = tmpAvailableBuildpacks.findIndex(
-        (buildpack) => buildpack.buildpack === buildpackToAdd
-      );
-      const buildpack = tmpAvailableBuildpacks[indexBuildpackToAdd];
-
-      setSelectedBuildpacks((selectedBuildpacks) => [
-        ...selectedBuildpacks,
-        buildpack,
-      ]);
-
-      tmpAvailableBuildpacks.splice(indexBuildpackToAdd, 1);
-      return [...tmpAvailableBuildpacks];
-    });
-  };
-
-  if (hide) {
-    return null;
-  }
-
-  if (!stackOptions?.length || !builderOptions?.length) {
-    return <Loading />;
-  }
-
-  return (
-    <BuildpackConfigurationContainer>
-      <>
-        <SelectRow
-          value={selectedBuilder}
-          width="100%"
-          options={builderOptions}
-          setActiveValue={(option) => handleSelectBuilder(option)}
-          label="Select a builder"
-        />
-
-        <SelectRow
-          value={selectedStack}
-          width="100%"
-          options={stackOptions}
-          setActiveValue={(option) => setSelectedStack(option)}
-          label="Select your stack"
-        />
-        <Helper>
-          The following buildpacks were automatically detected. You can also
-          manually add/remove buildpacks.
-        </Helper>
-
-        {!!selectedBuildpacks?.length &&
-          renderBuildpacksList(selectedBuildpacks, "remove")}
-
-        {!!availableBuildpacks?.length && (
-          <>
-            <Helper>Available buildpacks:</Helper>
-            {renderBuildpacksList(availableBuildpacks, "add")}
-          </>
-        )}
-      </>
-    </BuildpackConfigurationContainer>
-  );
-};
-
 const fadeIn = keyframes`
   from {
     opacity: 0;

+ 465 - 0
dashboard/src/components/repo-selector/BuildpackSelection.tsx

@@ -0,0 +1,465 @@
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
+import SelectRow from "components/form-components/SelectRow";
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ActionConfigType } from "shared/types";
+import styled, { keyframes } from "styled-components";
+
+const DEFAULT_BUILDER_NAME = "heroku";
+const DEFAULT_PAKETO_STACK = "paketobuildpacks/builder:full";
+const DEFAULT_HEROKU_STACK = "heroku/buildpacks:20";
+
+const URLRegex = /[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/;
+
+type BuildConfig = {
+  builder: string;
+  buildpacks: string[];
+  config: null | {
+    [key: string]: string;
+  };
+};
+
+type Buildpack = {
+  name: string;
+  buildpack: string;
+  config: {
+    [key: string]: string;
+  };
+};
+
+type DetectedBuildpack = {
+  name: string;
+  builders: string[];
+  detected: Buildpack[];
+  others: Buildpack[];
+};
+
+type DetectBuildpackResponse = DetectedBuildpack[];
+
+export const BuildpackSelection: React.FC<{
+  actionConfig: ActionConfigType;
+  folderPath: string;
+  branch: string;
+  hide: boolean;
+  onChange: (config: BuildConfig) => void;
+}> = ({ actionConfig, folderPath, branch, hide, onChange }) => {
+  const { currentProject } = useContext(Context);
+
+  const [builders, setBuilders] = useState<DetectedBuildpack[]>(null);
+  const [selectedBuilder, setSelectedBuilder] = useState<string>(null);
+
+  const [stacks, setStacks] = useState<string[]>(null);
+  const [selectedStack, setSelectedStack] = useState<string>(null);
+
+  const [selectedBuildpacks, setSelectedBuildpacks] = useState<Buildpack[]>([]);
+  const [availableBuildpacks, setAvailableBuildpacks] = useState<Buildpack[]>(
+    []
+  );
+
+  useEffect(() => {
+    let buildConfig: BuildConfig = {} as BuildConfig;
+
+    buildConfig.builder = selectedStack;
+    buildConfig.buildpacks = selectedBuildpacks?.map((buildpack) => {
+      return buildpack.buildpack;
+    });
+    if (typeof onChange === "function") {
+      onChange(buildConfig);
+    }
+  }, [selectedBuilder, selectedStack, selectedBuildpacks]);
+
+  useEffect(() => {
+    api
+      .detectBuildpack<DetectBuildpackResponse>(
+        "<token>",
+        {
+          dir: folderPath || ".",
+        },
+        {
+          project_id: currentProject.id,
+          git_repo_id: actionConfig.git_repo_id,
+          kind: "github",
+          owner: actionConfig.git_repo.split("/")[0],
+          name: actionConfig.git_repo.split("/")[1],
+          branch: branch,
+        }
+      )
+      // getMockData()
+      .then(({ data }) => {
+        const builders = data;
+
+        const defaultBuilder = builders.find(
+          (builder) => builder.name.toLowerCase() === DEFAULT_BUILDER_NAME
+        );
+
+        const detectedBuildpacks = defaultBuilder.detected;
+        const availableBuildpacks = defaultBuilder.others;
+        const defaultStack = defaultBuilder.builders.find((stack) => {
+          return (
+            stack === DEFAULT_HEROKU_STACK || stack === DEFAULT_PAKETO_STACK
+          );
+        });
+
+        setBuilders(builders);
+        setSelectedBuilder(defaultBuilder.name.toLowerCase());
+
+        setStacks(defaultBuilder.builders);
+        setSelectedStack(defaultStack);
+        if (!Array.isArray(detectedBuildpacks)) {
+          setSelectedBuildpacks([]);
+        } else {
+          setSelectedBuildpacks(detectedBuildpacks);
+        }
+        if (!Array.isArray(availableBuildpacks)) {
+          setAvailableBuildpacks([]);
+        } else {
+          setAvailableBuildpacks(availableBuildpacks);
+        }
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  }, [currentProject, actionConfig]);
+
+  const builderOptions = useMemo(() => {
+    if (!Array.isArray(builders)) {
+      return;
+    }
+
+    return builders.map((builder) => ({
+      label: builder.name,
+      value: builder.name.toLowerCase(),
+    }));
+  }, [builders]);
+
+  const stackOptions = useMemo(() => {
+    if (!Array.isArray(stacks)) {
+      return;
+    }
+
+    return stacks.map((stack) => ({
+      label: stack,
+      value: stack.toLowerCase(),
+    }));
+  }, [stacks]);
+
+  const handleSelectBuilder = (builderName: string) => {
+    const builder = builders.find(
+      (b) => b.name.toLowerCase() === builderName.toLowerCase()
+    );
+    const detectedBuildpacks = builder.detected;
+    const availableBuildpacks = builder.others;
+    const defaultStack = builder.builders.find((stack) => {
+      return stack === DEFAULT_HEROKU_STACK || stack === DEFAULT_PAKETO_STACK;
+    });
+    setSelectedBuilder(builderName);
+    setBuilders(builders);
+    setSelectedBuilder(builderName.toLowerCase());
+
+    setStacks(builder.builders);
+    setSelectedStack(defaultStack);
+
+    if (!Array.isArray(detectedBuildpacks)) {
+      setSelectedBuildpacks([]);
+    } else {
+      setSelectedBuildpacks(detectedBuildpacks);
+    }
+    if (!Array.isArray(availableBuildpacks)) {
+      setAvailableBuildpacks([]);
+    } else {
+      setAvailableBuildpacks(availableBuildpacks);
+    }
+  };
+
+  const renderBuildpacksList = (
+    buildpacks: Buildpack[],
+    action: "remove" | "add"
+  ) => {
+    return buildpacks?.map((buildpack) => {
+      const icon = `devicon-${buildpack?.name?.toLowerCase()}-plain colored`;
+      let disableIcon = false;
+      if (URLRegex.test(buildpack.buildpack)) {
+        disableIcon = true;
+      }
+
+      return (
+        <StyledCard key={buildpack.name}>
+          <ContentContainer>
+            <Icon disableMarginRight={disableIcon} className={icon} />
+            <EventInformation>
+              <EventName>{buildpack?.name}</EventName>
+            </EventInformation>
+          </ContentContainer>
+          <ActionContainer>
+            {action === "add" && (
+              <ActionButton
+                onClick={() => handleAddBuildpack(buildpack.buildpack)}
+              >
+                <span className="material-icons-outlined">add</span>
+              </ActionButton>
+            )}
+            {action === "remove" && (
+              <ActionButton
+                onClick={() => handleRemoveBuildpack(buildpack.buildpack)}
+              >
+                <span className="material-icons">delete</span>
+              </ActionButton>
+            )}
+          </ActionContainer>
+        </StyledCard>
+      );
+    });
+  };
+
+  const handleRemoveBuildpack = (buildpackToRemove: string) => {
+    setSelectedBuildpacks((selBuildpacks) => {
+      const tmpSelectedBuildpacks = [...selBuildpacks];
+
+      const indexBuildpackToRemove = tmpSelectedBuildpacks.findIndex(
+        (buildpack) => buildpack.buildpack === buildpackToRemove
+      );
+      const buildpack = tmpSelectedBuildpacks[indexBuildpackToRemove];
+
+      setAvailableBuildpacks((availableBuildpacks) => [
+        ...availableBuildpacks,
+        buildpack,
+      ]);
+
+      tmpSelectedBuildpacks.splice(indexBuildpackToRemove, 1);
+
+      return [...tmpSelectedBuildpacks];
+    });
+  };
+
+  const handleAddBuildpack = (buildpackToAdd: string) => {
+    setAvailableBuildpacks((avBuildpacks) => {
+      const tmpAvailableBuildpacks = [...avBuildpacks];
+      const indexBuildpackToAdd = tmpAvailableBuildpacks.findIndex(
+        (buildpack) => buildpack.buildpack === buildpackToAdd
+      );
+      const buildpack = tmpAvailableBuildpacks[indexBuildpackToAdd];
+
+      setSelectedBuildpacks((selectedBuildpacks) => [
+        ...selectedBuildpacks,
+        buildpack,
+      ]);
+
+      tmpAvailableBuildpacks.splice(indexBuildpackToAdd, 1);
+      return [...tmpAvailableBuildpacks];
+    });
+  };
+
+  const handleAddCustomBuildpack = (buildpack: Buildpack) => {
+    setSelectedBuildpacks((selectedBuildpacks) => [
+      ...selectedBuildpacks,
+      buildpack,
+    ]);
+  };
+
+  if (hide) {
+    return null;
+  }
+
+  if (!stackOptions?.length || !builderOptions?.length) {
+    return <Loading />;
+  }
+
+  return (
+    <BuildpackConfigurationContainer>
+      <>
+        <SelectRow
+          value={selectedBuilder}
+          width="100%"
+          options={builderOptions}
+          setActiveValue={(option) => handleSelectBuilder(option)}
+          label="Select a builder"
+        />
+
+        <SelectRow
+          value={selectedStack}
+          width="100%"
+          options={stackOptions}
+          setActiveValue={(option) => setSelectedStack(option)}
+          label="Select your stack"
+        />
+        <Helper>
+          The following buildpacks were automatically detected. You can also
+          manually add/remove buildpacks.
+        </Helper>
+
+        {!!selectedBuildpacks?.length &&
+          renderBuildpacksList(selectedBuildpacks, "remove")}
+
+        <Helper>Available buildpacks:</Helper>
+        {!!availableBuildpacks?.length && (
+          <>{renderBuildpacksList(availableBuildpacks, "add")}</>
+        )}
+        <Helper>
+          You may also add buildpacks by directly providing their GitHub links
+          or links to ZIP files that contain the buildpack source code.
+        </Helper>
+        <AddCustomBuildpackForm onAdd={handleAddCustomBuildpack} />
+      </>
+    </BuildpackConfigurationContainer>
+  );
+};
+
+const AddCustomBuildpackForm: React.FC<{
+  onAdd: (buildpack: Buildpack) => void;
+}> = ({ onAdd }) => {
+  const [buildpackUrl, setBuildpackUrl] = useState("");
+  const [error, setError] = useState(false);
+
+  const handleAddCustomBuildpack = () => {
+    if (!URLRegex.test(buildpackUrl)) {
+      setError(true);
+      return;
+    }
+
+    const buildpack: Buildpack = {
+      buildpack: buildpackUrl,
+      name: buildpackUrl,
+      config: null,
+    };
+    onAdd(buildpack);
+  };
+
+  return (
+    <StyledCard>
+      <ContentContainer>
+        <EventInformation>
+          <BuildpackInputContainer>
+            GitHub or ZIP URL
+            <BuildpackUrlInput
+              placeholder="https://github.com/custom/buildpack"
+              type="input"
+              value={buildpackUrl}
+              isRequired
+              setValue={(newUrl) => {
+                setError(false);
+                setBuildpackUrl(newUrl as string);
+              }}
+            />
+            <ErrorText hasError={error}>Please enter a valid url</ErrorText>
+          </BuildpackInputContainer>
+        </EventInformation>
+      </ContentContainer>
+      <ActionContainer>
+        <ActionButton onClick={() => handleAddCustomBuildpack()}>
+          <span className="material-icons-outlined">add</span>
+        </ActionButton>
+      </ActionContainer>
+    </StyledCard>
+  );
+};
+
+const ErrorText = styled.span`
+  color: red;
+  margin-left: 10px;
+  display: ${(props: { hasError: boolean }) =>
+    props.hasError ? "inline-block" : "none"};
+`;
+
+const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
+
+const BuildpackUrlInput = styled(InputRow)`
+  width: auto;
+  min-width: 150px;
+  max-width: 300px;
+  margin: unset;
+  margin-left: 10px;
+  display: inline-block;
+`;
+
+const BuildpackConfigurationContainer = styled.div`
+  animation: ${fadeIn} 0.75s;
+`;
+
+const StyledCard = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid #ffffff00;
+  background: #ffffff08;
+  margin-bottom: 5px;
+  border-radius: 8px;
+  padding: 14px;
+  overflow: hidden;
+  height: 60px;
+  font-size: 13px;
+  animation: ${fadeIn} 0.5s;
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+`;
+
+const Icon = styled.span<{ disableMarginRight: boolean }>`
+  font-size: 20px;
+  margin-left: 10px;
+  ${(props) => {
+    if (!props.disableMarginRight) {
+      return "margin-right: 20px";
+    }
+  }}
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const BuildpackInputContainer = styled(EventName)`
+  padding-left: 15px;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const ActionButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+
+  :hover {
+    background: #ffffff11;
+    border: 1px solid #ffffff44;
+  }
+
+  > span {
+    font-size: 20px;
+  }
+`;

+ 2 - 2
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -431,7 +431,7 @@ export default class ContentsList extends Component<PropsType, StateType> {
             <p>
               <b>{this.state.autoBuildpack.name}</b> buildpack was{" "}
               <a
-                href="https://docs.porter.run/docs/auto-deploy-requirements#auto-build-with-cloud-native-buildpacks"
+                href="https://docs.porter.run/deploying-applications/deploying-from-github/selecting-application-and-build-method#customizing-buildpacks"
                 target="_blank"
               >
                 detected automatically
@@ -446,7 +446,7 @@ export default class ContentsList extends Component<PropsType, StateType> {
           <FlexWrapper>
             <UseButton onClick={this.handleContinue}>Continue</UseButton>
             <StatusWrapper
-              href="https://docs.porter.run/docs/auto-deploy-requirements#auto-build-with-cloud-native-buildpacks"
+              href="https://docs.porter.run/deploying-applications/deploying-from-github/selecting-application-and-build-method#customizing-buildpacks"
               target="_blank"
             >
               <i className="material-icons">help_outline</i>

+ 21 - 0
dashboard/src/main/home/Home.tsx

@@ -136,8 +136,25 @@ class Home extends Component<PropsType, StateType> {
       .catch(console.log);
   };
 
+  checkIfCanCreateProject = () => {
+    api
+      .getCanCreateProject("<token>", {}, {})
+      .then((res) => {
+        if (res.status === 403) {
+          this.context.setCanCreateProject(false);
+          return;
+        }
+        this.context.setCanCreateProject(true);
+      })
+      .catch((err) => {
+        this.context.setCanCreateProject(false);
+        console.error(err);
+      });
+  };
+
   componentDidMount() {
     this.checkOnboarding();
+    this.checkIfCanCreateProject();
     let { match } = this.props;
 
     let { user } = this.context;
@@ -176,6 +193,10 @@ class Home extends Component<PropsType, StateType> {
     }
   }
 
+  componentWillUnmount(): void {
+    this.context.setCanCreateProject(false);
+  }
+
   async checkIfProjectHasBilling(projectId: number) {
     if (!projectId) {
       return false;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -49,7 +49,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     sortType: localStorage.getItem("SortType")
       ? localStorage.getItem("SortType")
       : "Newest",
-    lastRunStatus: null as null,
+    lastRunStatus: "all" as null,
     currentChart: null as ChartType | null,
     isMetricsInstalled: false,
   };

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/LastRunStatusSelector.tsx

@@ -13,7 +13,7 @@ const LastRunStatusSelector = (props: PropsType) => {
   const options = [
     {
       label: "All",
-      value: null,
+      value: "all",
     },
   ].concat(
     Object.entries(JobStatusType).map((status) => ({

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -78,7 +78,6 @@ const ChartList: React.FunctionComponent<Props> = ({
             "pending-install",
             "pending-upgrade",
             "pending-rollback",
-            "superseded",
             "failed",
           ],
         },

+ 286 - 16
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -10,9 +10,10 @@ import { ChartType, ClusterType, StorageType } from "shared/types";
 import { Context } from "shared/Context";
 import api from "shared/api";
 
-import SaveButton from "components/SaveButton";
+import Logs from "./status/Logs";
 import TitleSection from "components/TitleSection";
 import TempJobList from "./jobs/TempJobList";
+import TabRegion from "components/TabRegion";
 import SettingsSection from "./SettingsSection";
 import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
@@ -22,6 +23,8 @@ import Modal from "main/home/modals/Modal";
 import UpgradeChartModal from "main/home/modals/UpgradeChartModal";
 import { pushFiltered } from "../../../../shared/routing";
 import { RouteComponentProps, withRouter } from "react-router";
+import Banner from "components/Banner";
+import KeyValueArray from "components/form-components/KeyValueArray";
 
 type PropsType = WithAuthProps &
   RouteComponentProps & {
@@ -48,6 +51,8 @@ type StateType = {
   formData: any;
   devOpsMode: boolean;
   upgradeVersion: string;
+  expandedJobRun: any;
+  pods: any;
 };
 
 class ExpandedJobChart extends Component<PropsType, StateType> {
@@ -67,6 +72,30 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     formData: {} as any,
     upgradeVersion: "",
     devOpsMode: localStorage.getItem("devOpsMode") === "true",
+
+    expandedJobRun: null as any,
+    pods: null as any,
+  };
+
+  getPods = (job: any, callback?: () => void) => {
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+
+    api
+      .getJobPods(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+          name: job.metadata?.name,
+          cluster_id: currentCluster.id,
+          namespace: job.metadata?.namespace,
+        }
+      )
+      .then((res) => {
+        this.setState({ pods: res.data });
+        callback();
+      })
+      .catch((err) => setCurrentError(JSON.stringify(err)));
   };
 
   // Retrieve full chart data (includes form and values)
@@ -131,7 +160,7 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     pushFiltered(
       { location: this.props.location, history: this.props.history },
       this.props.match.url,
-      ["project_id"],
+      ["project_id", "job"],
       {
         chart_revision: this.state.currentChart.version,
       }
@@ -421,7 +450,17 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
   };
 
   sortJobsAndSave = (jobs: any[]) => {
+    // Set job run from URL if needed
+    const urlParams = new URLSearchParams(location.search);
+    const urlJob = urlParams.get("job");
+
     jobs.sort((job1, job2) => {
+      if (job1.metadata.name === urlJob) {
+        this.setJobRun(job1);
+      } else if (job2.metadata.name === urlJob) {
+        this.setJobRun(job2);
+      }
+
       let date1: Date = new Date(job1.status?.startTime);
       let date2: Date = new Date(job2.status?.startTime);
 
@@ -441,6 +480,12 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     }
   };
 
+  setJobRun = (job: any) => {
+    this.getPods(job, () => {
+      this.setState({ expandedJobRun: job, currentTab: "logs" });
+    });
+  };
+
   renderTabContents = (currentTab: string, submitValues?: any) => {
     switch (currentTab) {
       case "jobs":
@@ -471,6 +516,7 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
               setJobs={(jobs: any) => this.setState({ jobs })}
               isAuthorized={this.props.isAuthorized}
               saveValuesStatus={this.state.saveValuesStatus}
+              expandJob={(job: any) => this.setJobRun(job)}
             />
           </TabWrapper>
         );
@@ -643,7 +689,7 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     }
   };
 
-  render() {
+  renderExpandedChart() {
     let { closeChart } = this.props;
     let { currentChart } = this.state;
     let chart = currentChart;
@@ -696,17 +742,28 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
               </LastDeployed>
             </InfoWrapper>
             {displayUpdateButton && (
-              <RevisionUpdateMessage
-                onClick={(e) => {
-                  e.stopPropagation();
-                  this.setState({
-                    upgradeVersion: currentChart.latest_version,
-                  });
-                }}
-              >
-                <i className="material-icons">notification_important</i>
-                Template Update Available
-              </RevisionUpdateMessage>
+              <>
+                <Br />
+                <Banner>
+                  A template update is available.
+                  <Link
+                    onClick={(e) => {
+                      e.stopPropagation();
+                      this.setState({
+                        upgradeVersion: currentChart.latest_version,
+                      });
+                    }}
+                  >
+                    View upgrade notes
+                  </Link>
+                </Banner>
+                <Br />
+                <Br />
+                <Br />
+                <Br />
+                <Br />
+                <Br />
+              </>
             )}
           </HeaderWrapper>
 
@@ -763,12 +820,225 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
       </>
     );
   }
+
+  renderStatus = (job: any, time: string) => {
+    if (job.status?.succeeded >= 1) {
+      return <Status color="#38a88a">Succeeded {time}</Status>;
+    }
+
+    if (job.status?.failed >= 1) {
+      return (
+        <Status color="#cc3d42">
+          Failed {time}
+          {job.status.conditions.length > 0 &&
+            `: ${job.status.conditions[0].reason}`}
+        </Status>
+      );
+    }
+
+    return <Status color="#ffffff11">Running</Status>;
+  };
+
+  renderConfigSection = (job: any) => {
+    let commandString = job?.spec?.template?.spec?.containers[0]?.command?.join(
+      " "
+    );
+    let envArray = job?.spec?.template?.spec?.containers[0]?.env;
+    let envObject = {} as any;
+    envArray &&
+      envArray.forEach((env: any, i: number) => {
+        const secretName = _.get(env, "valueFrom.secretKeyRef.name");
+        envObject[env.name] = secretName
+          ? `PORTERSECRET_${secretName}`
+          : env.value;
+      });
+
+    // Handle no config to show
+    if (!commandString && _.isEmpty(envObject)) {
+      return <Placeholder>No config was found.</Placeholder>;
+    }
+
+    let tag = job.spec.template.spec.containers[0].image.split(":")[1];
+    return (
+      <ConfigSection>
+        {commandString ? (
+          <>
+            Command: <Command>{commandString}</Command>
+          </>
+        ) : (
+          <DarkMatter size="-18px" />
+        )}
+        <Row>
+          Image Tag: <Command>{tag}</Command>
+        </Row>
+        {!_.isEmpty(envObject) && (
+          <>
+            <KeyValueArray
+              envLoader={true}
+              values={envObject}
+              label="Environment Variables:"
+              disabled={true}
+            />
+            <DarkMatter />
+          </>
+        )}
+      </ConfigSection>
+    );
+  };
+
+  renderExpandedJobRun() {
+    let { currentChart } = this.state;
+    let chart = currentChart;
+    let run = this.state.expandedJobRun;
+
+    return (
+      <StyledExpandedChart>
+        <HeaderWrapper>
+          <BackButton onClick={() => this.setState({ expandedJobRun: null })}>
+            <BackButtonImg src={backArrow} />
+          </BackButton>
+          <TitleSection
+            icon={currentChart.chart.metadata.icon}
+            iconWidth="33px"
+          >
+            {chart.name}{" "}
+            <Gray>at {this.readableDate(run.status.startTime)}</Gray>
+          </TitleSection>
+
+          <InfoWrapper>
+            <LastDeployed>
+              {this.renderStatus(
+                run,
+                run.status.completionTime
+                  ? this.readableDate(run.status.completionTime)
+                  : ""
+              )}
+              <TagWrapper>
+                Namespace <NamespaceTag>{chart.namespace}</NamespaceTag>
+              </TagWrapper>
+              <DeploymentType currentChart={currentChart} />
+            </LastDeployed>
+          </InfoWrapper>
+        </HeaderWrapper>
+        <BodyWrapper>
+          <TabRegion
+            currentTab={this.state.currentTab}
+            setCurrentTab={(x: string) => this.setState({ currentTab: x })}
+            options={[
+              {
+                label: "Logs",
+                value: "logs",
+              },
+              {
+                label: "Config",
+                value: "config",
+              },
+            ]}
+          >
+            {this.state.currentTab === "logs" ? (
+              <JobLogsWrapper>
+                <Logs
+                  selectedPod={this.state.pods[0]}
+                  podError={!this.state.pods[0] ? "Pod no longer exists." : ""}
+                  rawText={true}
+                />
+              </JobLogsWrapper>
+            ) : (
+              <>{this.renderConfigSection(run)}</>
+            )}
+          </TabRegion>
+        </BodyWrapper>
+      </StyledExpandedChart>
+    );
+  }
+
+  render() {
+    return (
+      <>
+        {!this.state.expandedJobRun ? (
+          <>{this.renderExpandedChart()}</>
+        ) : (
+          <>{this.renderExpandedJobRun()}</>
+        )}
+      </>
+    );
+  }
 }
 
 ExpandedJobChart.contextType = Context;
 
 export default withRouter(withAuth(ExpandedJobChart));
 
+const Row = styled.div`
+  margin-top: 20px;
+`;
+
+const DarkMatter = styled.div<{ size?: string }>`
+  width: 100%;
+  margin-bottom: ${(props) => props.size || "-13px"};
+`;
+
+const Command = styled.span`
+  font-family: monospace;
+  color: #aaaabb;
+  margin-left: 7px;
+`;
+
+const ConfigSection = styled.div`
+  padding: 20px 30px 30px;
+  font-size: 13px;
+  font-weight: 500;
+  width: 100%;
+  border-radius: 8px;
+  background: #ffffff08;
+`;
+
+const JobLogsWrapper = styled.div`
+  min-height: 450px;
+  height: 55vh;
+  width: 100%;
+  border-radius: 8px;
+  background-color: black;
+  overflow-y: auto;
+`;
+
+const Div = styled.div`
+  width: 100%;
+  height: 100%;
+  background: red;
+`;
+
+const Status = styled.div<{ color: string }>`
+  padding: 5px 10px;
+  background: ${(props) => props.color};
+  font-size: 13px;
+  border-radius: 3px;
+  height: 25px;
+  color: #ffffff;
+  margin-bottom: -3px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Gray = styled.div`
+  color: #ffffff44;
+  margin-left: 15px;
+  font-weight: 400;
+  font-size: 18px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 2px;
+`;
+
+const Link = styled.div`
+  cursor: pointer;
+  margin-left: 5px;
+  color: #8590ff;
+`;
+
 const RevisionUpdateMessage = styled.button`
   background: none;
   color: white;
@@ -899,7 +1169,7 @@ const LastDeployed = styled.div`
 `;
 
 const TagWrapper = styled.div`
-  height: 20px;
+  height: 25px;
   font-size: 12px;
   display: flex;
   margin-left: 20px;
@@ -915,7 +1185,7 @@ const TagWrapper = styled.div`
 `;
 
 const NamespaceTag = styled.div`
-  height: 20px;
+  height: 100%;
   margin-left: 6px;
   color: #aaaabb;
   background: #43454a;

+ 2 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx

@@ -10,6 +10,7 @@ import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 type PropsType = WithAuthProps & {
   jobs: any[];
   setJobs: (job: any) => void;
+  expandJob: any;
 };
 
 type StateType = {
@@ -38,6 +39,7 @@ class JobList extends Component<PropsType, StateType> {
             return (
               <JobResource
                 key={job?.metadata?.name}
+                expandJob={this.props.expandJob}
                 job={job}
                 handleDelete={() => {
                   this.setState({ deletionCandidate: job });

+ 6 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx

@@ -14,6 +14,7 @@ type PropsType = {
   handleDelete: () => void;
   deleting: boolean;
   readOnly?: boolean;
+  expandJob: any;
 };
 
 type StateType = {
@@ -297,8 +298,11 @@ export default class JobResource extends Component<PropsType, StateType> {
                     delete
                   </i>
                 )}
-                <i className="material-icons" onClick={this.expandJob}>
-                  {this.state.expanded ? "expand_less" : "expand_more"}
+                <i
+                  className="material-icons"
+                  onClick={() => this.props.expandJob(this.props.job)}
+                >
+                  open_in_new
                 </i>
               </MaterialIconTray>
             </EndWrapper>

+ 6 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/TempJobList.tsx

@@ -11,6 +11,7 @@ interface Props {
   setJobs: any;
   jobs: any;
   handleSaveValues: any;
+  expandJob: any;
 }
 
 /**
@@ -45,7 +46,11 @@ const TempJobList: React.FC<Props> = (props) => {
   return (
     <>
       {saveButton}
-      <JobList jobs={props.jobs} setJobs={props.setJobs} />
+      <JobList
+        jobs={props.jobs}
+        setJobs={props.setJobs}
+        expandJob={props.expandJob}
+      />
     </>
   );
 };

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -200,7 +200,7 @@ const ControllerTabFC: React.FunctionComponent<Props> = ({
       status?.phase === "Pending" &&
       status?.containerStatuses !== undefined
     ) {
-      return status.containerStatuses[0].state.waiting.reason;
+      return status.containerStatuses[0].state?.waiting?.reason || "Pending";
     } else if (status?.phase === "Pending") {
       return "Pending";
     }
@@ -215,7 +215,7 @@ const ControllerTabFC: React.FunctionComponent<Props> = ({
       status?.containerStatuses?.forEach((s: any) => {
         if (s.state?.waiting) {
           collatedStatus =
-            s.state?.waiting.reason === "CrashLoopBackOff"
+            s.state?.waiting?.reason === "CrashLoopBackOff"
               ? "failed"
               : "waiting";
         } else if (s.state?.terminated) {

+ 127 - 31
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -1,8 +1,9 @@
-import React, { Component } from "react";
+import React, { Component, useEffect, useRef, useState } from "react";
 import styled from "styled-components";
 import { Context } from "shared/Context";
 import * as Anser from "anser";
 import api from "shared/api";
+import { useWebsockets } from "shared/hooks/useWebsockets";
 
 const MAX_LOGS = 1000;
 
@@ -18,6 +19,7 @@ type StateType = {
   ws: any;
   scroll: boolean;
   currentTab: string;
+  getPreviousLogs: boolean;
 };
 
 export default class Logs extends Component<PropsType, StateType> {
@@ -29,11 +31,43 @@ export default class Logs extends Component<PropsType, StateType> {
     ws: null as any,
     scroll: true,
     currentTab: "Application",
+    getPreviousLogs: false,
   };
 
   ws = null as any;
   parentRef = React.createRef<HTMLDivElement>();
 
+  getPodStatus = (status: any) => {
+    if (
+      status?.phase === "Pending" &&
+      status?.containerStatuses !== undefined
+    ) {
+      return status.containerStatuses[0].state.waiting.reason;
+    } else if (status?.phase === "Pending") {
+      return "Pending";
+    }
+
+    if (status?.phase === "Failed") {
+      return "failed";
+    }
+
+    if (status?.phase === "Running") {
+      let collatedStatus = "running";
+
+      status?.containerStatuses?.forEach((s: any) => {
+        if (s.state?.waiting) {
+          collatedStatus =
+            s.state?.waiting.reason === "CrashLoopBackOff"
+              ? "failed"
+              : "waiting";
+        } else if (s.state?.terminated) {
+          collatedStatus = "failed";
+        }
+      });
+      return collatedStatus;
+    }
+  };
+
   scrollToBottom = (smooth: boolean) => {
     if (smooth) {
       this.parentRef.current.lastElementChild.scrollIntoView({
@@ -69,6 +103,27 @@ export default class Logs extends Component<PropsType, StateType> {
       );
     }
 
+    if (
+      this.getPodStatus(selectedPod.status) === "failed" &&
+      this.state.logs.length === 0
+    ) {
+      return (
+        <Message>
+          No logs to display from this pod.
+          <Highlight
+            onClick={() => {
+              this.setState({ getPreviousLogs: true }, () => {
+                this.refreshLogs();
+              });
+            }}
+          >
+            <i className="material-icons">autorenew</i>
+            Get logs from crashed pod
+          </Highlight>
+        </Message>
+      );
+    }
+
     if (this.state.logs.length == 0) {
       return (
         <Message>
@@ -106,12 +161,18 @@ export default class Logs extends Component<PropsType, StateType> {
     let { selectedPod } = this.props;
     if (!selectedPod?.metadata?.name) return;
     let protocol = window.location.protocol == "https:" ? "wss" : "ws";
+    const currentTab = this.state.currentTab;
+    if (currentTab === "Application") {
+      this.ws = new WebSocket(
+        `${protocol}://${window.location.host}/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?previous=${this.state.getPreviousLogs}`
+      );
+    } else {
+      this.ws = new WebSocket(
+        `${protocol}://${window.location.host}/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?container_name=${currentTab}&previous=${this.state.getPreviousLogs}`
+      );
+    }
 
-    this.ws = new WebSocket(
-      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs`
-    );
-
-    this.ws.onopen = () => {};
+    this.ws.onopen = () => { };
 
     this.ws.onmessage = (evt: MessageEvent) => {
       let ansiLog = Anser.ansiToJson(evt.data);
@@ -141,14 +202,18 @@ export default class Logs extends Component<PropsType, StateType> {
       );
     };
 
-    this.ws.onerror = (err: ErrorEvent) => {};
+    this.ws.onerror = (err: ErrorEvent) => { };
 
-    this.ws.onclose = () => {};
+    this.ws.onclose = () => { };
   };
 
   refreshLogs = () => {
     let { selectedPod } = this.props;
-    if (this.ws && this.state.currentTab == "Application") {
+    if (
+      this.ws &&
+      typeof this.state.currentTab === "string" &&
+      this.state.currentTab != "System"
+    ) {
       this.ws.close();
       this.ws = null;
       this.setState({ logs: [] });
@@ -166,13 +231,14 @@ export default class Logs extends Component<PropsType, StateType> {
 
       this.setState({ logs: [] });
 
-      if (this.state.currentTab == "Application") {
-        this.setupWebsocket();
-        this.scrollToBottom(false);
+      if (this.state.currentTab == "System") {
+        this.retrieveEvents(selectedPod);
         return;
       }
 
-      this.retrieveEvents(selectedPod);
+      this.setState({ getPreviousLogs: false });
+      this.setupWebsocket();
+      this.scrollToBottom(false);
     }
   };
 
@@ -211,6 +277,15 @@ export default class Logs extends Component<PropsType, StateType> {
   componentDidMount() {
     let { selectedPod } = this.props;
 
+    if (selectedPod?.spec?.containers?.length > 1) {
+      const firstContainer = selectedPod?.spec?.containers[0];
+      this.setState({ currentTab: firstContainer?.name }, () => {
+        this.setupWebsocket();
+        this.scrollToBottom(false);
+      });
+      return;
+    }
+
     if (this.state.currentTab == "Application") {
       this.setupWebsocket();
       this.scrollToBottom(false);
@@ -226,20 +301,48 @@ export default class Logs extends Component<PropsType, StateType> {
     }
   }
 
-  render() {
-    if (this.props.rawText) {
+  renderContainerTabs = () => {
+    const containers = this.props.selectedPod?.spec?.containers;
+
+    if (!Array.isArray(containers) || containers?.length <= 1) {
       return (
-        <LogStreamAlt>
-          <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
-          <LogTabs>
+        <Tab
+          onClick={() => {
+            this.setState({ currentTab: "Application" });
+          }}
+          clicked={this.state.currentTab == "Application"}
+        >
+          Application
+        </Tab>
+      );
+    }
+
+    return (
+      <>
+        {containers.map((container: any) => {
+          return (
             <Tab
+              key={container.name}
               onClick={() => {
-                this.setState({ currentTab: "Application" });
+                this.setState({ currentTab: container.name });
               }}
-              clicked={this.state.currentTab == "Application"}
+              clicked={this.state.currentTab == container.name}
             >
-              Application
+              {container.name}
             </Tab>
+          );
+        })}
+      </>
+    );
+  };
+
+  render() {
+    if (this.props.rawText) {
+      return (
+        <LogStreamAlt>
+          <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
+          <LogTabs>
+            {this.renderContainerTabs()}
             <Tab
               onClick={() => {
                 this.setState({ currentTab: "System" });
@@ -262,7 +365,7 @@ export default class Logs extends Component<PropsType, StateType> {
               <input
                 type="checkbox"
                 checked={this.state.scroll}
-                onChange={() => {}}
+                onChange={() => { }}
               />
               Scroll to Bottom
             </Scroll>
@@ -283,14 +386,7 @@ export default class Logs extends Component<PropsType, StateType> {
       <LogStream>
         <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
         <LogTabs>
-          <Tab
-            onClick={() => {
-              this.setState({ currentTab: "Application" });
-            }}
-            clicked={this.state.currentTab == "Application"}
-          >
-            Application
-          </Tab>
+          {this.renderContainerTabs()}
           <Tab
             onClick={() => {
               this.setState({ currentTab: "System" });
@@ -313,7 +409,7 @@ export default class Logs extends Component<PropsType, StateType> {
             <input
               type="checkbox"
               checked={this.state.scroll}
-              onChange={() => {}}
+              onChange={() => { }}
             />
             Scroll to Bottom
           </Scroll>

+ 9 - 2
dashboard/src/main/home/launch/Launch.tsx

@@ -26,6 +26,8 @@ const tabOptions = [
   { label: "Community Add-ons", value: "community" },
 ];
 
+const HIDDEN_CHARTS = ["porter-agent"];
+
 type PropsType = RouteComponentProps & {};
 
 type StateType = {
@@ -74,7 +76,9 @@ class Templates extends Component<PropsType, StateType> {
         };
       });
       sortedVersionData.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
-
+      sortedVersionData = sortedVersionData.filter(
+        (template: any) => !HIDDEN_CHARTS.includes(template?.name)
+      );
       this.setState({ addonTemplates: sortedVersionData, error: false });
     } catch (error) {
       this.setState({ loading: false, error: true });
@@ -325,7 +329,10 @@ class Templates extends Component<PropsType, StateType> {
         <TemplatesWrapper>
           <TitleSection>
             Launch
-            <a href="https://docs.porter.run/docs/addons" target="_blank">
+            <a
+              href="https://docs.porter.run/deploying-applications/overview"
+              target="_blank"
+            >
               <i className="material-icons">help_outline</i>
             </a>
           </TitleSection>

+ 1 - 1
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -175,7 +175,7 @@ class SettingsPage extends Component<PropsType, StateType> {
           </Placeholder>
           <SaveButton
             text="Deploy"
-            onClick={onSubmit}
+            onClick={() => onSubmit({})}
             status={saveValuesStatus}
             makeFlush={true}
           />

+ 1 - 1
dashboard/src/main/home/launch/launch-flow/SourcePage.tsx

@@ -278,7 +278,7 @@ class SourcePage extends Component<PropsType, StateType> {
         <Helper>
           Learn more about
           <Highlight
-            href="https://docs.porter.run/docs/applications"
+            href="https://docs.porter.run/deploying-applications/overview"
             target="_blank"
           >
             deploying services to Porter

+ 1 - 1
dashboard/src/main/home/launch/launch-flow/WorkflowPage.tsx

@@ -90,7 +90,7 @@ const WorkflowPage: React.FC<PropsType> = (props) => {
           recommend you <b>deploy from docker instead</b>, and checkout this
           guide:{" "}
           <a
-            href="https://docs.porter.run/docs/auto-deploy-requirements#cicd-with-github-actions"
+            href="https://docs.porter.run/deploying-applications/deploying-from-github/customizing-github-workflow"
             target="_blank"
           >
             CI/CD with GitHub Actions

+ 12 - 2
dashboard/src/main/home/new-project/NewProject.tsx

@@ -1,4 +1,4 @@
-import React, { useContext, useMemo, useState } from "react";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 
 import { useRouting } from "shared/routing";
 import api from "shared/api";
@@ -15,6 +15,7 @@ import { isAlphanumeric } from "shared/common";
 import InputRow from "components/form-components/InputRow";
 import Helper from "components/form-components/Helper";
 import TitleSection from "components/TitleSection";
+import { trackCreateNewProject } from "shared/anayltics";
 
 type ValidationError = {
   hasError: boolean;
@@ -22,12 +23,20 @@ type ValidationError = {
 };
 
 export const NewProjectFC = () => {
-  const { user, setProjects, setCurrentProject } = useContext(Context);
+  const { user, setProjects, setCurrentProject, canCreateProject } = useContext(
+    Context
+  );
   const { pushFiltered } = useRouting();
   const [buttonStatus, setButtonStatus] = useState("");
   const [name, setName] = useState("");
   const { projects } = useContext(Context);
 
+  useEffect(() => {
+    if (!canCreateProject) {
+      pushFiltered("/", []);
+    }
+  }, [canCreateProject]);
+
   const isFirstProject = useMemo(() => {
     return !(projects?.length >= 1);
   }, [projects]);
@@ -86,6 +95,7 @@ export const NewProjectFC = () => {
       setProjects(projectList);
       setCurrentProject(project);
       setButtonStatus("successful");
+      trackCreateNewProject();
       pushFiltered("/onboarding", []);
     } catch (error) {
       setButtonStatus("Couldn't create project, try again.");

+ 13 - 8
dashboard/src/main/home/onboarding/steps/ConnectRegistry/ConnectRegistry.tsx

@@ -15,8 +15,9 @@ import { OFState } from "../../state";
 import { useSnapshot } from "valtio";
 import api from "shared/api";
 import Loading from "components/Loading";
-import { integrationList } from "shared/common";
 import Registry from "./components/Registry";
+import { connectRegistryTracks } from "shared/anayltics";
+import DocsHelper from "components/DocsHelper";
 
 const ConnectRegistry: React.FC<{}> = ({}) => {
   const snap = useSnapshot(OFState);
@@ -78,11 +79,15 @@ const ConnectRegistry: React.FC<{}> = ({}) => {
   };
 
   const handleSkip = () => {
+    connectRegistryTracks.trackSkipRegistryConnection();
     OFState.actions.nextStep("skip");
   };
 
   const handleSelectProvider = (provider: string) => {
-    provider !== "skip" && OFState.actions.nextStep("continue", provider);
+    if (provider !== "skip") {
+      connectRegistryTracks.trackConnectRegistryIntent({ provider });
+      OFState.actions.nextStep("continue", provider);
+    }
   };
 
   const handleContinueWithCurrent = () => {
@@ -120,12 +125,12 @@ const ConnectRegistry: React.FC<{}> = ({}) => {
       <TitleSection>Getting Started</TitleSection>
       <Subtitle>
         Step 2 of 3 - Connect an existing registry (Optional)
-        <a
-          href="https://docs.porter.run/docs/linking-up-application-source#connecting-an-existing-image-registry"
-          target="_blank"
-        >
-          <i className="material-icons">help_outline</i>
-        </a>
+        <DocsHelper
+          tooltipText="If you already have an existing image registry, you can connect your existing registry during project creation. If you don't have an image registry or don't know what that means, skip this step. Porter will handle the rest."
+          link={
+            "https://docs.porter.run/getting-started/linking-application-source#connecting-an-existing-image-registry"
+          }
+        />
       </Subtitle>
       <Helper>
         {currentProvider

+ 35 - 4
dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/FormFlow.tsx

@@ -28,6 +28,8 @@ import {
 } from "./_GCPRegistryForm";
 import { OFState } from "main/home/onboarding/state";
 import { useSnapshot } from "valtio";
+import { connectRegistryTracks, trackRedirectToGuide } from "shared/anayltics";
+import { StepHandler } from "main/home/onboarding/state/StepHandler";
 
 const Forms = {
   aws: {
@@ -52,19 +54,19 @@ const FormTitle = {
     label: "Amazon Elastic Container Registry (ECR)",
     icon: integrationList["ecr"].icon,
     doc:
-      "https://docs.porter.run/docs/linking-an-existing-docker-container-registry#amazon-elastic-container-registry-ecr",
+      "https://docs.porter.run/deploying-applications/deploying-from-docker-registry/linking-existing-registry#amazon-elastic-container-registry-ecr",
   },
   gcp: {
     label: "Google Container Registry (GCR)",
     icon: integrationList["gcr"].icon,
     doc:
-      "https://docs.porter.run/docs/linking-an-existing-docker-container-registry#google-container-registry-gcr",
+      "https://docs.porter.run/deploying-applications/deploying-from-docker-registry/linking-existing-registry#google-container-registry-gcr",
   },
   do: {
     label: "DigitalOcean Container Registry (DOCR)",
     icon: integrationList["do"].icon,
     doc:
-      "https://docs.porter.run/docs/linking-an-existing-docker-container-registry#digitalocean-container-registry",
+      "https://docs.porter.run/deploying-applications/deploying-from-docker-registry/linking-existing-registry#digital-ocean-container-registry",
   },
 };
 
@@ -74,6 +76,7 @@ type Props = {
 
 const FormFlowWrapper: React.FC<Props> = ({ currentStep }) => {
   const snap = useSnapshot(StateHandler);
+  const stepHandler = useSnapshot(StepHandler);
 
   const provider = snap.connected_registry.provider as SupportedProviders;
   const project = snap.project;
@@ -90,8 +93,15 @@ const FormFlowWrapper: React.FC<Props> = ({ currentStep }) => {
     data?: Partial<Exclude<ConnectedRegistryConfig, SkipRegistryConnection>>
   ) => {
     if (currentStep === "credentials") {
+      connectRegistryTracks.trackRegistryAddCredentials({
+        provider: provider,
+        step: stepHandler.currentStepName,
+      });
       handleContinue(data.credentials);
     } else if (currentStep === "settings") {
+      connectRegistryTracks.trackConnectRegistryClicked({
+        provider: provider,
+      });
       handleContinue(data.settings);
     } else if (currentStep === "test_connection") {
       handleContinue();
@@ -127,7 +137,28 @@ const FormFlowWrapper: React.FC<Props> = ({ currentStep }) => {
           {FormTitle[provider] && <img src={FormTitle[provider].icon} />}
           {FormTitle[provider] && FormTitle[provider].label}
         </FormHeader>
-        <GuideButton href={FormTitle[provider].doc} target="_blank">
+        <GuideButton
+          href={FormTitle[provider].doc}
+          target="_blank"
+          onAuxClick={() => {
+            trackRedirectToGuide({
+              step: stepHandler.currentStepName,
+              guide_url: FormTitle[provider].doc,
+              provider,
+            });
+            // Will allow the anchor tag to redirect properly
+            return true;
+          }}
+          onClick={() => {
+            trackRedirectToGuide({
+              step: stepHandler.currentStepName,
+              guide_url: FormTitle[provider].doc,
+              provider,
+            });
+            // Will allow the anchor tag to redirect properly
+            return true;
+          }}
+        >
           <i className="material-icons-outlined">help</i>
           Guide
         </GuideButton>

+ 23 - 6
dashboard/src/main/home/onboarding/steps/ConnectSource.tsx

@@ -8,6 +8,8 @@ import { useRouting } from "shared/routing";
 import styled from "styled-components";
 import { OFState } from "../state";
 import github from "assets/github.png";
+import { connectSourceTracks } from "shared/anayltics";
+import DocsHelper from "components/DocsHelper";
 
 interface GithubAppAccessData {
   username?: string;
@@ -60,6 +62,11 @@ const ConnectSource: React.FC<{
   }, []);
 
   const nextStep = (selectedSource: "docker" | "github") => {
+    if (selectedSource === "docker") {
+      connectSourceTracks.trackUseDockerRegistryClicked();
+    } else {
+      connectSourceTracks.trackContinueAfterGithubConnect();
+    }
     onSuccess(selectedSource);
   };
 
@@ -72,12 +79,12 @@ const ConnectSource: React.FC<{
       <TitleSection>Getting Started</TitleSection>
       <Subtitle>
         Step 1 of 3 - Connect to GitHub
-        <a
-          href="https://docs.porter.run/docs/linking-up-application-source"
-          target="_blank"
-        >
-          <i className="material-icons">help_outline</i>
-        </a>
+        <DocsHelper
+          tooltipText="Porter uses a GitHub App to authorize and gain access to your GitHub repositories. In order to be able to deploy applications through GitHub repositories, you must first authorize the Porter GitHub App to have access to them."
+          link={
+            "https://docs.porter.run/getting-started/linking-application-source#connecting-to-github"
+          }
+        />
       </Subtitle>
       <Helper>
         To deploy applications from your repo, you need to connect a Github
@@ -87,6 +94,11 @@ const ConnectSource: React.FC<{
         <>
           <ConnectToGithubButton
             href={`/api/integrations/github-app/install?redirect_uri=${encoded_redirect_uri}`}
+            onClick={() => {
+              connectSourceTracks.trackConnectGithubButtonClicked();
+              // Will allow the anchor tag to redirect properly
+              return true;
+            }}
           >
             <GitHubIcon src={github} /> Connect to GitHub
           </ConnectToGithubButton>
@@ -120,6 +132,11 @@ const ConnectSource: React.FC<{
             Don't see the right repos?{" "}
             <A
               href={`/api/integrations/github-app/install?redirect_uri=${encoded_redirect_uri}`}
+              onClick={() => {
+                connectSourceTracks.trackInstallOnMoreRepositoriesClicked();
+                // Will allow the anchor tag to redirect properly
+                return true;
+              }}
             >
               Install Porter in more repositories
             </A>

+ 14 - 1
dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx

@@ -15,6 +15,8 @@ import backArrow from "assets/back_arrow.png";
 import { StatusPage } from "./forms/StatusPage";
 import { useSnapshot } from "valtio";
 import { OFState } from "../../state";
+import { provisionResourcesTracks } from "shared/anayltics";
+import DocsHelper from "components/DocsHelper";
 
 type Props = {};
 
@@ -42,9 +44,11 @@ const ProvisionResources: React.FC<Props> = () => {
 
   const handleSelectProvider = (provider: string) => {
     if (provider !== "external") {
+      provisionResourcesTracks.trackProvisionIntent({ provider });
       OFState.actions.nextStep("continue", provider);
       return;
     }
+    provisionResourcesTracks.trackConnectExternalClusterIntent();
     OFState.actions.nextStep("skip");
   };
 
@@ -155,7 +159,15 @@ const ProvisionResources: React.FC<Props> = () => {
         </BackButton>
       )}
       <TitleSection>Getting Started</TitleSection>
-      <Subtitle>Step 3 of 3 - Provision resources</Subtitle>
+      <Subtitle>
+        Step 3 of 3 - Provision resources
+        <DocsHelper
+          tooltipText="Porter provisions and manages the underlying infrastructure in your own cloud. It is not necessary to know about the provisioned resources to use Porter."
+          link={
+            "https://docs.porter.run/getting-started/provisioning-infrastructure#faq"
+          }
+        />
+      </Subtitle>
       <Helper>
         Porter automatically creates a cluster and registry in your cloud to run
         applications.
@@ -177,6 +189,7 @@ const Subtitle = styled.div`
   font-size: 16px;
   font-weight: 500;
   margin-top: 16px;
+  display: flex;
 `;
 
 const NextStep = styled(SaveButton)`

+ 43 - 4
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/FormFlow.tsx

@@ -26,6 +26,11 @@ import {
 } from "./_GCPProvisionerForm";
 import { OFState } from "main/home/onboarding/state";
 import { useSnapshot } from "valtio";
+import {
+  provisionResourcesTracks,
+  trackRedirectToGuide,
+} from "shared/anayltics";
+import { StepHandler } from "main/home/onboarding/state/StepHandler";
 
 const Forms = {
   aws: {
@@ -46,17 +51,17 @@ const FormTitle = {
   aws: {
     label: "Amazon Web Services (AWS)",
     icon: integrationList["aws"].icon,
-    doc: "https://docs.porter.run/docs/getting-started-on-aws",
+    doc: "https://docs.porter.run/getting-started/provisioning-on-aws",
   },
   gcp: {
     label: "Google Cloud Platform (GCP)",
     icon: integrationList["gcp"].icon,
-    doc: "https://docs.porter.run/docs/provisioning-on-google-cloud",
+    doc: "https://docs.porter.run/getting-started/provisioning-on-gcp",
   },
   do: {
     label: "DigitalOcean (DO)",
     icon: integrationList["do"].icon,
-    doc: "https://docs.porter.run/docs/provisioning-on-digital-ocean",
+    doc: "https://docs.porter.run/getting-started/provisioning-on-do",
   },
   external: {
     label: "Connect an existing cluster",
@@ -71,6 +76,7 @@ type Props = {
 
 const FormFlowWrapper: React.FC<Props> = ({ currentStep }) => {
   const snap = useSnapshot(StateHandler);
+  const stepHandler = useSnapshot(StepHandler);
 
   const provider = snap.provision_resources?.provider as
     | SupportedProviders
@@ -90,8 +96,20 @@ const FormFlowWrapper: React.FC<Props> = ({ currentStep }) => {
     data?: Partial<Exclude<ProvisionerConfig, SkipProvisionConfig>>
   ) => {
     if (currentStep === "credentials") {
+      provisionResourcesTracks.trackProvisionAddCredentials({
+        provider: provider,
+        step: stepHandler.currentStepName,
+      });
       handleContinue(data);
     } else if (currentStep === "settings") {
+      const settings: any = data?.settings;
+      provisionResourcesTracks.trackProvisionResourcesClicked({
+        provider: provider,
+        cluster_name: settings?.cluster_name,
+        machine_type: settings?.aws_machine_type,
+        region: settings?.region,
+        subscription_tier: settings?.tier,
+      });
       handleContinue(data);
     }
   };
@@ -125,7 +143,28 @@ const FormFlowWrapper: React.FC<Props> = ({ currentStep }) => {
           {FormTitle[provider] && <img src={FormTitle[provider].icon} />}
           {FormTitle[provider] && FormTitle[provider].label}
         </FormHeader>
-        <GuideButton href={FormTitle[provider]?.doc} target="_blank">
+        <GuideButton
+          href={FormTitle[provider]?.doc}
+          target="_blank"
+          onAuxClick={() => {
+            trackRedirectToGuide({
+              step: stepHandler.currentStepName,
+              guide_url: FormTitle[provider].doc,
+              provider,
+            });
+            // Will allow the anchor tag to redirect properly
+            return true;
+          }}
+          onClick={() => {
+            trackRedirectToGuide({
+              step: stepHandler.currentStepName,
+              guide_url: FormTitle[provider].doc,
+              provider,
+            });
+            // Will allow the anchor tag to redirect properly
+            return true;
+          }}
+        >
           <i className="material-icons-outlined">help</i>
           Guide
         </GuideButton>

+ 31 - 4
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/StatusPage.tsx

@@ -54,6 +54,8 @@ export const StatusPage = ({
   project_id,
   setInfraStatus,
 }: Props) => {
+  const isMounted = useRef(false);
+
   const {
     newWebsocket,
     openWebsocket,
@@ -144,7 +146,10 @@ export const StatusPage = ({
     } catch (error) {}
   };
 
-  const getDesiredState = async (infra_id: number) => {
+  const getDesiredState = async (infra_id: number, counter: number = 0) => {
+    if (!isMounted.current) {
+      return;
+    }
     try {
       const desired = await api
         .getInfraDesired("<token>", {}, { project_id, infra_id })
@@ -158,9 +163,24 @@ export const StatusPage = ({
       connectToLiveUpdateModule(infra_id);
     } catch (error) {
       console.error(error);
-      setTimeout(() => {
-        getDesiredState(infra_id);
-      }, 500);
+      const MIN_TIMEOUT = 500;
+      const MAX_TIMEOUT = 2000;
+
+      let timeout = counter * 500;
+
+      if (timeout < MIN_TIMEOUT) {
+        timeout = MIN_TIMEOUT;
+      }
+
+      if (timeout > MAX_TIMEOUT) {
+        timeout = MAX_TIMEOUT;
+      }
+
+      if (isMounted.current) {
+        setTimeout(() => {
+          getDesiredState(infra_id, counter + 1);
+        }, timeout);
+      }
     }
   };
 
@@ -266,6 +286,13 @@ export const StatusPage = ({
     openWebsocket(websocketId);
   };
 
+  useEffect(() => {
+    isMounted.current = true;
+    return () => {
+      isMounted.current = false;
+    };
+  }, []);
+
   useEffect(() => {
     getInfras();
     return () => {

+ 1 - 1
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_AWSProvisionerForm.tsx

@@ -416,7 +416,7 @@ export const SettingsForm: React.FC<{
           setMachineType(x);
         }}
         label="⚙️ AWS Machine Type"
-        doc="https://docs.porter.run/docs/provisioning-infrastructure#which-instance-type-should-i-select"
+        doc="https://docs.porter.run/getting-started/provisioning-infrastructure#which-instance-type-should-i-select"
       />
       <Br />
       <SaveButton

+ 5 - 1
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_ConnectExternalCluster.tsx

@@ -4,6 +4,7 @@ import TabSelector from "components/TabSelector";
 import api from "shared/api";
 import SaveButton from "components/SaveButton";
 import { integrationList } from "shared/common";
+import { provisionResourcesTracks } from "shared/anayltics";
 
 type Props = {
   nextStep: () => void;
@@ -173,7 +174,10 @@ const ConnectExternalCluster: React.FC<Props> = ({
       <NextStep
         text="Continue"
         disabled={!enableContinue}
-        onClick={() => nextStep()}
+        onClick={() => {
+          provisionResourcesTracks.trackExternalClusterConnected();
+          nextStep();
+        }}
         status={
           !enableContinue ? "No connected cluster detected" : "successful"
         }

+ 1 - 0
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_GCPProvisionerForm.tsx

@@ -335,6 +335,7 @@ export const SettingsForm: React.FC<{
         cluster_name: clusterName,
         registry_infra_id: registryProvisionResponse?.id,
         cluster_infra_id: clusterProvisionResponse?.id,
+        region,
       },
     });
   };

+ 1 - 0
dashboard/src/main/home/onboarding/types.ts

@@ -73,6 +73,7 @@ export type GCPProvisionerConfig = {
     cluster_name: string;
     registry_infra_id: number;
     cluster_infra_id: number;
+    region: string;
   };
 };
 

+ 12 - 10
dashboard/src/main/home/sidebar/ProjectSection.tsx

@@ -74,16 +74,18 @@ class ProjectSection extends Component<PropsType, StateType> {
         <div>
           <Dropdown>
             {this.renderOptionList()}
-            <Option
-              selected={false}
-              lastItem={true}
-              onClick={() =>
-                pushFiltered(this.props, "/new-project", ["project_id"])
-              }
-            >
-              <ProjectIconAlt>+</ProjectIconAlt>
-              <ProjectLabel>Create a Project</ProjectLabel>
-            </Option>
+            {this.context.canCreateProject && (
+              <Option
+                selected={false}
+                lastItem={true}
+                onClick={() =>
+                  pushFiltered(this.props, "/new-project", ["project_id"])
+                }
+              >
+                <ProjectIconAlt>+</ProjectIconAlt>
+                <ProjectLabel>Create a Project</ProjectLabel>
+              </Option>
+            )}
           </Dropdown>
         </div>
       );

+ 6 - 0
dashboard/src/shared/Context.tsx

@@ -60,6 +60,8 @@ export interface GlobalContextType {
   queryUsage: (retry?: number) => Promise<void>;
   hasFinishedOnboarding: boolean;
   setHasFinishedOnboarding: (onboardingStatus: boolean) => void;
+  canCreateProject: boolean;
+  setCanCreateProject: (canCreateProject: boolean) => void;
 }
 
 /**
@@ -181,6 +183,10 @@ class ContextProvider extends Component<PropsType, StateType> {
     setHasFinishedOnboarding: (onboardingStatus) => {
       this.setState({ hasFinishedOnboarding: onboardingStatus });
     },
+    canCreateProject: false,
+    setCanCreateProject: (canCreateProject: boolean) => {
+      this.setState({ canCreateProject });
+    },
   };
 
   render() {

+ 1 - 0
dashboard/src/shared/anayltics/index.ts

@@ -0,0 +1 @@
+export * from "./onboarding/tracks";

+ 29 - 0
dashboard/src/shared/anayltics/onboarding/events.ts

@@ -0,0 +1,29 @@
+export enum COMMON_TRACKS {
+  REDIRECT_TO_GUIDE = "FE Redirect to guide",
+}
+
+export enum PROJECT_CREATION_TRACKS {
+  NEW_PROJECT_EVENT = "FE Create project",
+}
+
+export enum CONNECT_SOURCE_TRACKS {
+  CONNECT_GITHUB_BUTTON_CLICKED = "FE Connect Github",
+  USE_DOCKER_REGISTRY_CLICKED = "FE Use docker registry",
+  CONTINUE_AFTER_GITHUB_CONNECT = "FE Continue after Github connect",
+  INSTALL_ON_MORE_REPOSITORIES_CLICKED = "FE Install on more repositories",
+}
+
+export enum CONNECT_REGISTRY_TRACKS {
+  SKIP_REGISTRY_CONNECTION = "FE Skip registry connection",
+  INTENT = "FE Connect registry intent",
+  ADD_CREDENTIALS = "FE Connect registry added credentials",
+  CONNECT_REGISTRY_CLICKED = "FE Connect registry clicked",
+}
+
+export enum PROVISION_RESOURCES_TRACKS {
+  PROVISION_INTENT = "FE Provision resources intent",
+  ADD_CREDENTIALS = "FE Provision resources added credentials",
+  PROVISION_RESOURCES_CLICKED = "FE Provision resources clicked",
+  CONNECT_EXTERNAL_CLUSTER_INTENT = "FE Provision resources Connect external cluster intent",
+  CONNECTED_EXTERNAL_CLUSTER = "FE Provision resources Connected external cluster",
+}

+ 88 - 0
dashboard/src/shared/anayltics/onboarding/tracks.ts

@@ -0,0 +1,88 @@
+import type {
+  TrackConnectRegistryClickedProps,
+  TrackConnectRegistryIntentProps,
+  TrackProvisionAddCredentialsProps,
+  TrackProvisionIntentProps,
+  TrackProvisionResourcesClickedProps,
+  TrackRedirectToGuideProps,
+  TrackRegistryAddCredentialsProps,
+} from "./types";
+import {
+  COMMON_TRACKS,
+  CONNECT_REGISTRY_TRACKS,
+  CONNECT_SOURCE_TRACKS,
+  PROJECT_CREATION_TRACKS,
+  PROVISION_RESOURCES_TRACKS,
+} from "./events";
+
+export function trackCreateNewProject() {
+  window.analytics?.track(PROJECT_CREATION_TRACKS.NEW_PROJECT_EVENT);
+}
+
+export function trackRedirectToGuide(props: TrackRedirectToGuideProps) {
+  window.analytics?.track(COMMON_TRACKS.REDIRECT_TO_GUIDE, props);
+}
+
+export const connectSourceTracks = {
+  trackConnectGithubButtonClicked() {
+    window.analytics?.track(
+      CONNECT_SOURCE_TRACKS.CONNECT_GITHUB_BUTTON_CLICKED
+    );
+  },
+  trackUseDockerRegistryClicked() {
+    window.analytics?.track(CONNECT_SOURCE_TRACKS.USE_DOCKER_REGISTRY_CLICKED);
+  },
+  trackContinueAfterGithubConnect() {
+    window.analytics?.track(
+      CONNECT_SOURCE_TRACKS.CONTINUE_AFTER_GITHUB_CONNECT
+    );
+  },
+  trackInstallOnMoreRepositoriesClicked() {
+    window.analytics?.track(
+      CONNECT_SOURCE_TRACKS.INSTALL_ON_MORE_REPOSITORIES_CLICKED
+    );
+  },
+};
+
+export const connectRegistryTracks = {
+  trackSkipRegistryConnection() {
+    window.analytics?.track(CONNECT_REGISTRY_TRACKS.SKIP_REGISTRY_CONNECTION);
+  },
+  trackConnectRegistryIntent(props: TrackConnectRegistryIntentProps) {
+    window.analytics?.track(CONNECT_REGISTRY_TRACKS.INTENT, props);
+  },
+  trackRegistryAddCredentials(props: TrackRegistryAddCredentialsProps) {
+    window.analytics?.track(CONNECT_REGISTRY_TRACKS.ADD_CREDENTIALS, props);
+  },
+  trackConnectRegistryClicked(props: TrackConnectRegistryClickedProps) {
+    window.analytics?.track(
+      CONNECT_REGISTRY_TRACKS.CONNECT_REGISTRY_CLICKED,
+      props
+    );
+  },
+};
+
+export const provisionResourcesTracks = {
+  trackConnectExternalClusterIntent() {
+    window.analytics?.track(
+      PROVISION_RESOURCES_TRACKS.CONNECT_EXTERNAL_CLUSTER_INTENT
+    );
+  },
+  trackExternalClusterConnected() {
+    window.analytics?.track(
+      PROVISION_RESOURCES_TRACKS.CONNECTED_EXTERNAL_CLUSTER
+    );
+  },
+  trackProvisionIntent(props: TrackProvisionIntentProps) {
+    window.analytics?.track(PROVISION_RESOURCES_TRACKS.PROVISION_INTENT, props);
+  },
+  trackProvisionAddCredentials(props: TrackProvisionAddCredentialsProps) {
+    window.analytics?.track(PROVISION_RESOURCES_TRACKS.ADD_CREDENTIALS, props);
+  },
+  trackProvisionResourcesClicked(props: TrackProvisionResourcesClickedProps) {
+    window.analytics?.track(
+      PROVISION_RESOURCES_TRACKS.PROVISION_RESOURCES_CLICKED,
+      props
+    );
+  },
+};

+ 35 - 0
dashboard/src/shared/anayltics/onboarding/types.ts

@@ -0,0 +1,35 @@
+export type TrackRedirectToGuideProps = {
+  step: string;
+  guide_url: string;
+  provider?: string;
+};
+
+export type TrackConnectRegistryIntentProps = {
+  provider: string;
+};
+
+export type TrackRegistryAddCredentialsProps = {
+  step: string;
+  provider: string;
+};
+
+export type TrackConnectRegistryClickedProps = {
+  provider: string;
+};
+
+export type TrackProvisionIntentProps = {
+  provider: string;
+};
+
+export type TrackProvisionAddCredentialsProps = {
+  step: string;
+  provider: string;
+};
+
+export type TrackProvisionResourcesClickedProps = {
+  provider: string;
+  cluster_name: string;
+  machine_type?: string;
+  subscription_tier?: string;
+  region?: string;
+};

+ 6 - 0
dashboard/src/shared/api.tsx

@@ -1321,6 +1321,11 @@ const getLogBucketLogs = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/kube_events/${kube_event_id}/logs`
 );
 
+const getCanCreateProject = baseApi<{}, {}>(
+  "GET",
+  () => "/api/can_create_project"
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1452,4 +1457,5 @@ export default {
   getKubeEvent,
   getLogBuckets,
   getLogBucketLogs,
+  getCanCreateProject,
 };

+ 2 - 0
dashboard/src/shared/types.tsx

@@ -306,6 +306,8 @@ export interface ContextProps {
   queryUsage: () => Promise<void>;
   hasFinishedOnboarding: boolean;
   setHasFinishedOnboarding: (onboardingStatus: boolean) => void;
+  canCreateProject: boolean;
+  setCanCreateProject: (canCreateProject: boolean) => void;
 }
 
 export enum JobStatusType {

+ 4 - 0
docs/guides/preserving-client-ip-addresses.md

@@ -1,5 +1,9 @@
 # AWS
 
+> 🚧 Update: December 10 2021
+>
+> Please note that clusters provisioned on Amazon EKS after December 10 2021 will have support for proxying external IPs configured by default.
+
 > 🚧
 > 
 > Changing this configuration may result in a few minutes of downtime. It is recommended to set up client IP addresses before the application is live, or update it during a maintenance window. For more information, see [this Github issue](https://github.com/porter-dev/porter/issues/632#issuecomment-832939982).

+ 58 - 3
internal/helm/agent.go

@@ -1,7 +1,10 @@
 package helm
 
 import (
+	"context"
 	"fmt"
+	"strconv"
+	"strings"
 
 	"github.com/pkg/errors"
 	"github.com/porter-dev/porter/internal/helm/loader"
@@ -9,6 +12,8 @@ import (
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/chart"
 	"helm.sh/helm/v3/pkg/release"
+	corev1 "k8s.io/api/core/v1"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/helm/pkg/chartutil"
 
 	"github.com/porter-dev/porter/api/types"
@@ -28,11 +33,61 @@ func (a *Agent) ListReleases(
 	namespace string,
 	filter *types.ReleaseListFilter,
 ) ([]*release.Release, error) {
-	cmd := action.NewList(a.ActionConfig)
+	lsel := fmt.Sprintf("owner=helm,status in (%s)", strings.Join(filter.StatusFilter, ","))
 
-	filter.Apply(cmd)
+	// list secrets
+	secretList, err := a.K8sAgent.Clientset.CoreV1().Secrets(namespace).List(
+		context.Background(),
+		v1.ListOptions{
+			LabelSelector: lsel,
+		},
+	)
 
-	return cmd.Run()
+	if err != nil {
+		return nil, err
+	}
+
+	// before decoding to helm release, only keep the latest releases for each chart
+	latestMap := make(map[string]corev1.Secret)
+
+	for _, secret := range secretList.Items {
+		relName, relNameExists := secret.Labels["name"]
+
+		if !relNameExists {
+			continue
+		}
+
+		id := fmt.Sprintf("%s/%s", secret.Namespace, relName)
+
+		if currLatest, exists := latestMap[id]; exists {
+			// get version
+			currVersionStr, currVersionExists := currLatest.Labels["version"]
+			versionStr, versionExists := secret.Labels["version"]
+
+			if versionExists && currVersionExists {
+				currVersion, currErr := strconv.Atoi(currVersionStr)
+				version, err := strconv.Atoi(versionStr)
+				if currErr == nil && err == nil && currVersion < version {
+					latestMap[id] = secret
+				}
+			}
+		} else {
+			latestMap[id] = secret
+		}
+	}
+
+	chartList := []string{}
+	res := make([]*release.Release, 0)
+
+	for _, secret := range latestMap {
+		rel, isErr, err := kubernetes.ParseSecretToHelmRelease(secret, chartList)
+
+		if !isErr && err == nil {
+			res = append(res, rel)
+		}
+	}
+
+	return res, nil
 }
 
 // GetRelease returns the info of a release.

+ 77 - 77
internal/helm/agent_test.go

@@ -108,88 +108,88 @@ type listReleaseTest struct {
 	expRes    []releaseStub
 }
 
-var listReleaseTests = []listReleaseTest{
-	{
-		name:      "simple test across namespaces, should sort by name",
-		namespace: "",
-		filter: &types.ReleaseListFilter{
-			Namespace:    "",
-			Limit:        20,
-			Skip:         0,
-			ByDate:       false,
-			StatusFilter: []string{"deployed"},
-		},
-		releases: []releaseStub{
-			{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
-			{"wordpress", "default", 1, "1.0.1", release.StatusDeployed},
-			{"not-in-default-namespace", "other", 1, "1.0.2", release.StatusDeployed},
-		},
-		expRes: []releaseStub{
-			{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
-			{"not-in-default-namespace", "other", 1, "1.0.2", release.StatusDeployed},
-			{"wordpress", "default", 1, "1.0.1", release.StatusDeployed},
-		},
-	},
-	{
-		name:      "simple test only default namespace",
-		namespace: "default",
-		filter: &types.ReleaseListFilter{
-			Namespace:    "",
-			Limit:        20,
-			Skip:         0,
-			ByDate:       false,
-			StatusFilter: []string{"deployed"},
-		},
-		releases: []releaseStub{
-			{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
-			{"wordpress", "default", 1, "1.0.1", release.StatusDeployed},
-			{"not-in-default-namespace", "other", 1, "1.0.2", release.StatusDeployed},
-		},
-		expRes: []releaseStub{
-			{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
-			{"wordpress", "default", 1, "1.0.1", release.StatusDeployed},
-		},
-	},
-	{
-		name:      "simple test limit",
-		namespace: "",
-		filter: &types.ReleaseListFilter{
-			Namespace:    "",
-			Limit:        2,
-			Skip:         0,
-			ByDate:       false,
-			StatusFilter: []string{"deployed"},
-		},
-		releases: []releaseStub{
-			{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
-			{"not-in-default-namespace", "other", 1, "1.0.1", release.StatusDeployed},
-			{"wordpress", "default", 1, "1.0.2", release.StatusDeployed},
-		},
-		expRes: []releaseStub{
-			{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
-			{"not-in-default-namespace", "other", 1, "1.0.1", release.StatusDeployed},
-		},
-	},
-}
+// var listReleaseTests = []listReleaseTest{
+// 	{
+// 		name:      "simple test across namespaces, should sort by name",
+// 		namespace: "",
+// 		filter: &types.ReleaseListFilter{
+// 			Namespace:    "",
+// 			Limit:        20,
+// 			Skip:         0,
+// 			ByDate:       false,
+// 			StatusFilter: []string{"deployed"},
+// 		},
+// 		releases: []releaseStub{
+// 			{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
+// 			{"wordpress", "default", 1, "1.0.1", release.StatusDeployed},
+// 			{"not-in-default-namespace", "other", 1, "1.0.2", release.StatusDeployed},
+// 		},
+// 		expRes: []releaseStub{
+// 			{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
+// 			{"not-in-default-namespace", "other", 1, "1.0.2", release.StatusDeployed},
+// 			{"wordpress", "default", 1, "1.0.1", release.StatusDeployed},
+// 		},
+// 	},
+// 	{
+// 		name:      "simple test only default namespace",
+// 		namespace: "default",
+// 		filter: &types.ReleaseListFilter{
+// 			Namespace:    "",
+// 			Limit:        20,
+// 			Skip:         0,
+// 			ByDate:       false,
+// 			StatusFilter: []string{"deployed"},
+// 		},
+// 		releases: []releaseStub{
+// 			{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
+// 			{"wordpress", "default", 1, "1.0.1", release.StatusDeployed},
+// 			{"not-in-default-namespace", "other", 1, "1.0.2", release.StatusDeployed},
+// 		},
+// 		expRes: []releaseStub{
+// 			{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
+// 			{"wordpress", "default", 1, "1.0.1", release.StatusDeployed},
+// 		},
+// 	},
+// 	{
+// 		name:      "simple test limit",
+// 		namespace: "",
+// 		filter: &types.ReleaseListFilter{
+// 			Namespace:    "",
+// 			Limit:        2,
+// 			Skip:         0,
+// 			ByDate:       false,
+// 			StatusFilter: []string{"deployed"},
+// 		},
+// 		releases: []releaseStub{
+// 			{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
+// 			{"not-in-default-namespace", "other", 1, "1.0.1", release.StatusDeployed},
+// 			{"wordpress", "default", 1, "1.0.2", release.StatusDeployed},
+// 		},
+// 		expRes: []releaseStub{
+// 			{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
+// 			{"not-in-default-namespace", "other", 1, "1.0.1", release.StatusDeployed},
+// 		},
+// 	},
+// }
 
-func TestListReleases(t *testing.T) {
-	for _, tc := range listReleaseTests {
-		agent := newAgentFixture(t, tc.namespace)
-		makeReleases(t, agent, tc.releases)
+// func TestListReleases(t *testing.T) {
+// 	for _, tc := range listReleaseTests {
+// 		agent := newAgentFixture(t, tc.namespace)
+// 		makeReleases(t, agent, tc.releases)
 
-		// calling agent.ActionConfig.Releases.Create in makeReleases will automatically set the
-		// namespace, so we have to reset the namespace of the storage driver
-		agent.ActionConfig.Releases.Driver.(*driver.Memory).SetNamespace(tc.namespace)
+// 		// calling agent.ActionConfig.Releases.Create in makeReleases will automatically set the
+// 		// namespace, so we have to reset the namespace of the storage driver
+// 		agent.ActionConfig.Releases.Driver.(*driver.Memory).SetNamespace(tc.namespace)
 
-		releases, err := agent.ListReleases(tc.namespace, tc.filter)
+// 		releases, err := agent.ListReleases(tc.namespace, tc.filter)
 
-		if err != nil {
-			t.Errorf("%v", err)
-		}
+// 		if err != nil {
+// 			t.Errorf("%v", err)
+// 		}
 
-		compareReleaseToStubs(t, releases, tc.expRes)
-	}
-}
+// 		compareReleaseToStubs(t, releases, tc.expRes)
+// 	}
+// }
 
 type getReleaseTest struct {
 	name       string

+ 21 - 5
internal/integrations/ci/actions/actions.go

@@ -21,7 +21,8 @@ import (
 )
 
 type GithubActions struct {
-	ServerURL string
+	ServerURL    string
+	InstanceName string
 
 	GithubOAuthIntegration *models.GitRepo
 	GitRepoName            string
@@ -78,7 +79,7 @@ func (g *GithubActions) Setup() ([]byte, error) {
 
 	if !g.DryRun {
 		// create porter token secret
-		if err := createGithubSecret(client, getPorterTokenSecretName(g.ProjectID), g.PorterToken, g.GitRepoOwner, g.GitRepoName); err != nil {
+		if err := createGithubSecret(client, g.getPorterTokenSecretName(), g.PorterToken, g.GitRepoOwner, g.GitRepoName); err != nil {
 			return nil, err
 		}
 	}
@@ -190,7 +191,7 @@ func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
 	gaSteps := []GithubActionYAMLStep{
 		getCheckoutCodeStep(),
 		getSetTagStep(),
-		getUpdateAppStep(g.ServerURL, getPorterTokenSecretName(g.ProjectID), g.ProjectID, g.ClusterID, g.ReleaseName, g.ReleaseNamespace, g.Version),
+		getUpdateAppStep(g.ServerURL, g.getPorterTokenSecretName(), g.ProjectID, g.ClusterID, g.ReleaseName, g.ReleaseNamespace, g.Version),
 	}
 
 	branch := g.GitBranch
@@ -357,13 +358,28 @@ func (g *GithubActions) getBuildEnvSecretName() string {
 }
 
 func (g *GithubActions) getPorterYMLFileName() string {
+	if g.InstanceName != "" {
+		return fmt.Sprintf("porter_%s_%s.yml", strings.Replace(
+			strings.ToLower(g.ReleaseName), "-", "_", -1),
+			strings.ToLower(g.InstanceName),
+		)
+	}
+
 	return fmt.Sprintf("porter_%s.yml", strings.Replace(
 		strings.ToLower(g.ReleaseName), "-", "_", -1),
 	)
 }
 
-func getPorterTokenSecretName(projID uint) string {
-	return fmt.Sprintf("PORTER_TOKEN_%d", projID)
+func (g *GithubActions) getPorterTokenSecretName() string {
+	if g.InstanceName != "" {
+		return fmt.Sprintf("PORTER_TOKEN_%s_%d", strings.ToUpper(g.InstanceName), g.ProjectID)
+	}
+
+	return fmt.Sprintf("PORTER_TOKEN_%d", g.ProjectID)
+}
+
+func getPorterTokenSecretName(projectID uint) string {
+	return fmt.Sprintf("PORTER_TOKEN_%d", projectID)
 }
 
 func commitGithubFile(

+ 11 - 0
internal/integrations/slack/notifier.go

@@ -49,6 +49,8 @@ type NotifyOpts struct {
 
 	URL string
 
+	Timestamp *time.Time
+
 	Version int
 }
 
@@ -151,6 +153,15 @@ func getSlackBlocks(opts *NotifyOpts) ([]*SlackBlock, []*SlackBlock) {
 		getMarkdownBlock(fmt.Sprintf("*Namespace:* %s", "`"+opts.Namespace+"`")),
 	)
 
+	if opts.Timestamp != nil {
+		res = append(res, getMarkdownBlock(fmt.Sprintf(
+			"*Timestamp:* <!date^%d^Alerted at {date_num} {time_secs}|Alerted at %s>",
+			opts.Timestamp.Unix(),
+			opts.Timestamp.Format("2006-01-02 15:04:05 UTC"),
+		)),
+		)
+	}
+
 	if opts.Status == StatusHelmDeployed || opts.Status == StatusHelmFailed {
 		res = append(res, getMarkdownBlock(fmt.Sprintf("*Version:* %d", opts.Version)))
 	}

+ 15 - 8
internal/kubernetes/agent.go

@@ -554,7 +554,7 @@ func (a *Agent) DeletePod(namespace string, name string) error {
 }
 
 // GetPodLogs streams real-time logs from a given pod.
-func (a *Agent) GetPodLogs(namespace string, name string, rw *websocket.WebsocketSafeReadWriter) error {
+func (a *Agent) GetPodLogs(namespace string, name string, showPreviousLogs bool, selectedContainer string, rw *websocket.WebsocketSafeReadWriter) error {
 	// get the pod to read in the list of contains
 	pod, err := a.Clientset.CoreV1().Pods(namespace).Get(
 		context.Background(),
@@ -568,9 +568,11 @@ func (a *Agent) GetPodLogs(namespace string, name string, rw *websocket.Websocke
 		return fmt.Errorf("Cannot get logs from pod %s: %s", name, err.Error())
 	}
 
-	// see if container is ready and able to open a stream. If not, wait for container
-	// to be ready.
-	err, _ = a.waitForPod(pod)
+	if !showPreviousLogs {
+		// see if container is ready and able to open a stream. If not, wait for container
+		// to be ready.
+		err, _ = a.waitForPod(pod)
+	}
 
 	if err != nil && goerrors.Is(err, IsNotFoundError) {
 		return IsNotFoundError
@@ -580,6 +582,10 @@ func (a *Agent) GetPodLogs(namespace string, name string, rw *websocket.Websocke
 
 	container := pod.Spec.Containers[0].Name
 
+	if len(selectedContainer) > 0 {
+		container = selectedContainer
+	}
+
 	tails := int64(400)
 
 	// follow logs
@@ -587,6 +593,7 @@ func (a *Agent) GetPodLogs(namespace string, name string, rw *websocket.Websocke
 		Follow:    true,
 		TailLines: &tails,
 		Container: container,
+		Previous:  showPreviousLogs,
 	}
 
 	req := a.Clientset.CoreV1().Pods(namespace).GetLogs(name, &podLogOpts)
@@ -871,7 +878,7 @@ func contains(s []string, str string) bool {
 	return false
 }
 
-func parseSecretToHelmRelease(secret v1.Secret, chartList []string) (*rspb.Release, bool, error) {
+func ParseSecretToHelmRelease(secret v1.Secret, chartList []string) (*rspb.Release, bool, error) {
 	if secret.Type != "helm.sh/release.v1" {
 		return nil, true, nil
 	}
@@ -929,7 +936,7 @@ func (a *Agent) StreamHelmReleases(namespace string, chartList []string, selecto
 					return
 				}
 
-				helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
+				helm_object, isNotHelmRelease, err := ParseSecretToHelmRelease(*secretObj, chartList)
 
 				if isNotHelmRelease && err == nil {
 					return
@@ -955,7 +962,7 @@ func (a *Agent) StreamHelmReleases(namespace string, chartList []string, selecto
 					return
 				}
 
-				helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
+				helm_object, isNotHelmRelease, err := ParseSecretToHelmRelease(*secretObj, chartList)
 
 				if isNotHelmRelease && err == nil {
 					return
@@ -981,7 +988,7 @@ func (a *Agent) StreamHelmReleases(namespace string, chartList []string, selecto
 					return
 				}
 
-				helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
+				helm_object, isNotHelmRelease, err := ParseSecretToHelmRelease(*secretObj, chartList)
 
 				if isNotHelmRelease && err == nil {
 					return

+ 12 - 0
internal/models/allowlist.go

@@ -0,0 +1,12 @@
+package models
+
+import (
+	"gorm.io/gorm"
+)
+
+// Allowlist is a simple list with all the users emails allowed to create new projects
+type Allowlist struct {
+	gorm.Model
+
+	UserEmail string `json:"user_email" gorm:"unique;not null"`
+}

+ 17 - 0
internal/models/notification.go

@@ -37,3 +37,20 @@ func notifLimitToTime(notifTime string) time.Time {
 	// TODO: compute a time that's not just 5 min
 	return time.Now().Add(-10 * time.Minute)
 }
+
+type JobNotificationConfig struct {
+	gorm.Model
+
+	Name      string
+	Namespace string
+
+	ProjectID uint
+	ClusterID uint
+
+	LastNotifiedTime time.Time
+}
+
+func (conf *JobNotificationConfig) ShouldNotify() bool {
+	// check the last notified time against the notification limit
+	return conf.LastNotifiedTime.Before(time.Now().Add(-24 * time.Hour))
+}

+ 7 - 0
internal/repository/allowlist.go

@@ -0,0 +1,7 @@
+package repository
+
+// AllowlistRepository represents the set of queries on the
+// Allowlist model
+type AllowlistRepository interface {
+	UserEmailExists(email string) (bool, error)
+}

+ 33 - 0
internal/repository/gorm/allowlist.go

@@ -0,0 +1,33 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// AllowlistRepository uses gorm.DB for querying the database
+type AllowlistRepository struct {
+	db *gorm.DB
+}
+
+// NewAllowlistRepository returns a AllowListRepository which uses
+// gorm.DB for querying the database.
+func NewAllowlistRepository(db *gorm.DB) repository.AllowlistRepository {
+	return &AllowlistRepository{db}
+}
+
+func (repo *AllowlistRepository) UserEmailExists(email string) (bool, error) {
+	al := &models.Allowlist{}
+	result := repo.db.Where("user_email = ?", email).Find(&al)
+
+	if err := result.Error; err != nil {
+		return false, err
+	}
+
+	if result.RowsAffected > 0 {
+		return true, nil
+	}
+
+	return false, nil
+}

+ 49 - 0
internal/repository/gorm/allowlist_test.go

@@ -0,0 +1,49 @@
+package gorm_test
+
+import (
+	"testing"
+)
+
+func TestUserEmailExistsOnAllowlist(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_allowlist.db",
+	}
+
+	setupTestEnv(tester, t)
+	initAllowlist(tester, t)
+	defer cleanup(tester, t)
+
+	expected := true
+
+	found, err := tester.repo.Allowlist().UserEmailExists("some@email.com")
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if found != expected {
+		t.Errorf("expected found to be %t but got: %t", expected, found)
+	}
+}
+
+func TestUserDontExistsOnAllowList(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_allowlist.db",
+	}
+
+	setupTestEnv(tester, t)
+	initAllowlist(tester, t)
+	defer cleanup(tester, t)
+
+	expected := false
+
+	found, err := tester.repo.Allowlist().UserEmailExists("nonexisting@email.com")
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if found != expected {
+		t.Errorf("expected found to be %t but got: %t", expected, found)
+	}
+}

+ 2 - 7
internal/repository/gorm/event.go

@@ -1,7 +1,6 @@
 package gorm
 
 import (
-	"fmt"
 	"strings"
 	"time"
 
@@ -93,13 +92,11 @@ func (repo *KubeEventRepository) CreateEvent(
 		return nil, err
 	}
 
-	fmt.Println("COUNT IS", event.Name, count)
-
 	// if the count is greater than 500, remove the lowest-order event to implement a
 	// basic fixed-length buffer
 	if count >= 500 {
 		// first, delete the matching sub events
-		err := repo.db.Debug().Exec(`
+		err := repo.db.Exec(`
 		  DELETE FROM kube_sub_events 
 		  WHERE kube_event_id IN (
 			SELECT id FROM kube_events k2 WHERE (k2.project_id = ? AND k2.cluster_id = ?) AND k2.id NOT IN (
@@ -113,7 +110,7 @@ func (repo *KubeEventRepository) CreateEvent(
 		}
 
 		// then, delete the matching events
-		err = repo.db.Debug().Exec(`
+		err = repo.db.Exec(`
 		  DELETE FROM kube_events 
 		  WHERE (project_id = ? AND cluster_id = ?) AND id NOT IN (
 			SELECT id FROM kube_events k2 WHERE (k2.project_id = ? AND k2.cluster_id = ?) ORDER BY k2.updated_at desc, k2.id desc LIMIT 499
@@ -248,8 +245,6 @@ func (repo *KubeEventRepository) AppendSubEvent(event *models.KubeEvent, subEven
 		return err
 	}
 
-	fmt.Println("COUNT IS (subevents)", event.Name, count)
-
 	// if the count is greater than 20, remove the lowest-order events to implement a
 	// basic fixed-length buffer
 	if count >= 20 {

+ 18 - 1
internal/repository/gorm/helpers_test.go

@@ -13,12 +13,15 @@ import (
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository/gorm"
+
+	_gorm "gorm.io/gorm"
 )
 
 type tester struct {
 	repo           repository.Repository
 	key            *[32]byte
 	dbFileName     string
+	db             *_gorm.DB
 	initUsers      []*models.User
 	initProjects   []*models.Project
 	initGRs        []*models.GitRepo
@@ -36,6 +39,7 @@ type tester struct {
 	initOAuths     []*ints.OAuthIntegration
 	initGCPs       []*ints.GCPIntegration
 	initAWSs       []*ints.AWSIntegration
+	initAllowlist  []*models.Allowlist
 }
 
 func setupTestEnv(tester *tester, t *testing.T) {
@@ -71,6 +75,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.KubeEvent{},
 		&models.KubeSubEvent{},
 		&models.Onboarding{},
+		&models.Allowlist{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
@@ -94,7 +99,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 	}
 
 	tester.key = &key
-
+	tester.db = db
 	tester.repo = gorm.NewRepository(db, &key, nil)
 }
 
@@ -122,6 +127,18 @@ func initUser(tester *tester, t *testing.T) {
 	tester.initUsers = append(tester.initUsers, user)
 }
 
+func initAllowlist(tester *tester, t *testing.T) {
+	t.Helper()
+
+	allowedUser := &models.Allowlist{
+		UserEmail: "some@email.com",
+	}
+
+	tester.db.Create(&allowedUser)
+
+	tester.initAllowlist = append(tester.initAllowlist, allowedUser)
+}
+
 func initMultiUser(tester *tester, t *testing.T) {
 	t.Helper()
 

+ 2 - 0
internal/repository/gorm/migrate.go

@@ -29,6 +29,7 @@ func AutoMigrate(db *gorm.DB) error {
 		&models.DNSRecord{},
 		&models.PWResetToken{},
 		&models.NotificationConfig{},
+		&models.JobNotificationConfig{},
 		&models.EventContainer{},
 		&models.SubEvent{},
 		&models.KubeEvent{},
@@ -38,6 +39,7 @@ func AutoMigrate(db *gorm.DB) error {
 		&models.Onboarding{},
 		&models.CredentialsExchangeToken{},
 		&models.BuildConfig{},
+		&models.Allowlist{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 62 - 0
internal/repository/gorm/notification.go

@@ -42,3 +42,65 @@ func (repo NotificationConfigRepository) UpdateNotificationConfig(am *models.Not
 
 	return am, nil
 }
+
+type JobNotificationConfigRepository struct {
+	db *gorm.DB
+}
+
+// NewJobNotificationConfigRepository creates a new JobNotificationConfigRepository
+func NewJobNotificationConfigRepository(db *gorm.DB) repository.JobNotificationConfigRepository {
+	return JobNotificationConfigRepository{db: db}
+}
+
+// CreateNotificationConfig creates a new JobNotificationConfig
+func (repo JobNotificationConfigRepository) CreateNotificationConfig(am *models.JobNotificationConfig) (*models.JobNotificationConfig, error) {
+	var count int64
+
+	query := repo.db.Where("project_id = ? AND cluster_id = ?", am.ProjectID, am.ClusterID)
+
+	if err := query.Model([]*models.JobNotificationConfig{}).Count(&count).Error; err != nil {
+		return nil, err
+	}
+
+	// if the count is greater than 1000, remove the lowest-order events to implement a
+	// basic fixed-length buffer
+	if count >= 1000 {
+		err := repo.db.Exec(`
+			  DELETE FROM job_notification_configs 
+			  WHERE project_id = ? AND cluster_id = ? AND 
+			  id NOT IN (
+				SELECT id FROM job_notification_configs j2 WHERE j2.project_id = ? AND j2.cluster_id = ? ORDER BY j2.updated_at desc, j2.id desc LIMIT 999
+			  )
+			`, am.ProjectID, am.ClusterID, am.ProjectID, am.ClusterID).Error
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	if err := repo.db.Create(am).Error; err != nil {
+		return nil, err
+	}
+
+	return am, nil
+}
+
+// ReadNotificationConfig reads a JobNotificationConfig by ID
+func (repo JobNotificationConfigRepository) ReadNotificationConfig(projID, clusterID uint, name, namespace string) (*models.JobNotificationConfig, error) {
+	ret := &models.JobNotificationConfig{}
+
+	if err := repo.db.Where("project_id = ? AND cluster_id = ? AND name = ? AND namespace = ?", projID, clusterID, name, namespace).First(&ret).Error; err != nil {
+		return nil, err
+	}
+
+	return ret, nil
+}
+
+// UpdateNotificationConfig updates a given JobNotificationConfig
+func (repo JobNotificationConfigRepository) UpdateNotificationConfig(am *models.JobNotificationConfig) (*models.JobNotificationConfig, error) {
+	if err := repo.db.Save(am).Error; err != nil {
+		return nil, err
+	}
+
+	return am, nil
+}

+ 12 - 0
internal/repository/gorm/repository.go

@@ -32,12 +32,14 @@ type GormRepository struct {
 	githubAppOAuthIntegration repository.GithubAppOAuthIntegrationRepository
 	slackIntegration          repository.SlackIntegrationRepository
 	notificationConfig        repository.NotificationConfigRepository
+	jobNotificationConfig     repository.JobNotificationConfigRepository
 	buildEvent                repository.BuildEventRepository
 	kubeEvent                 repository.KubeEventRepository
 	projectUsage              repository.ProjectUsageRepository
 	onboarding                repository.ProjectOnboardingRepository
 	ceToken                   repository.CredentialsExchangeTokenRepository
 	buildConfig               repository.BuildConfigRepository
+	allowlist                 repository.AllowlistRepository
 }
 
 func (t *GormRepository) User() repository.UserRepository {
@@ -140,6 +142,10 @@ func (t *GormRepository) NotificationConfig() repository.NotificationConfigRepos
 	return t.notificationConfig
 }
 
+func (t *GormRepository) JobNotificationConfig() repository.JobNotificationConfigRepository {
+	return t.jobNotificationConfig
+}
+
 func (t *GormRepository) BuildEvent() repository.BuildEventRepository {
 	return t.buildEvent
 }
@@ -164,6 +170,10 @@ func (t *GormRepository) BuildConfig() repository.BuildConfigRepository {
 	return t.buildConfig
 }
 
+func (t *GormRepository) Allowlist() repository.AllowlistRepository {
+	return t.allowlist
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.CredentialStorage) repository.Repository {
@@ -193,11 +203,13 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		githubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(db),
 		slackIntegration:          NewSlackIntegrationRepository(db, key),
 		notificationConfig:        NewNotificationConfigRepository(db),
+		jobNotificationConfig:     NewJobNotificationConfigRepository(db),
 		buildEvent:                NewBuildEventRepository(db),
 		kubeEvent:                 NewKubeEventRepository(db, key),
 		projectUsage:              NewProjectUsageRepository(db),
 		onboarding:                NewProjectOnboardingRepository(db),
 		ceToken:                   NewCredentialsExchangeTokenRepository(db),
 		buildConfig:               NewBuildConfigRepository(db),
+		allowlist:                 NewAllowlistRepository(db),
 	}
 }

+ 6 - 0
internal/repository/notification.go

@@ -9,3 +9,9 @@ type NotificationConfigRepository interface {
 	ReadNotificationConfig(id uint) (*models.NotificationConfig, error)
 	UpdateNotificationConfig(am *models.NotificationConfig) (*models.NotificationConfig, error)
 }
+
+type JobNotificationConfigRepository interface {
+	CreateNotificationConfig(am *models.JobNotificationConfig) (*models.JobNotificationConfig, error)
+	ReadNotificationConfig(projID, clusterID uint, name, namespace string) (*models.JobNotificationConfig, error)
+	UpdateNotificationConfig(am *models.JobNotificationConfig) (*models.JobNotificationConfig, error)
+}

+ 2 - 0
internal/repository/repository.go

@@ -26,10 +26,12 @@ type Repository interface {
 	GithubAppOAuthIntegration() GithubAppOAuthIntegrationRepository
 	SlackIntegration() SlackIntegrationRepository
 	NotificationConfig() NotificationConfigRepository
+	JobNotificationConfig() JobNotificationConfigRepository
 	BuildEvent() BuildEventRepository
 	KubeEvent() KubeEventRepository
 	ProjectUsage() ProjectUsageRepository
 	Onboarding() ProjectOnboardingRepository
 	CredentialsExchangeToken() CredentialsExchangeTokenRepository
 	BuildConfig() BuildConfigRepository
+	Allowlist() AllowlistRepository
 }

+ 41 - 0
internal/repository/test/allowlist.go

@@ -0,0 +1,41 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// AllowlistRepository uses gorm.DB for querying the database
+type AllowlistRepository struct {
+	canQuery  bool
+	allowlist []*models.Allowlist
+}
+
+// NewAllowlistRepository returns a AllowListRepository which uses
+// gorm.DB for querying the database.
+func NewAllowlistRepository(canQuery bool) repository.AllowlistRepository {
+	return &AllowlistRepository{canQuery, []*models.Allowlist{}}
+}
+
+func (repo *AllowlistRepository) UserEmailExists(email string) (bool, error) {
+	if !repo.canQuery {
+		return false, errors.New("cannot read database")
+	}
+
+	if len(repo.allowlist) == 0 {
+		return false, nil
+	}
+
+	founded := false
+
+	for _, allowed := range repo.allowlist {
+		if allowed.UserEmail == email {
+			founded = true
+			break
+		}
+	}
+
+	return founded, nil
+}

+ 18 - 0
internal/repository/test/notification.go

@@ -22,3 +22,21 @@ func (n *NotificationConfigRepository) ReadNotificationConfig(id uint) (*models.
 func (n *NotificationConfigRepository) UpdateNotificationConfig(am *models.NotificationConfig) (*models.NotificationConfig, error) {
 	panic("not implemented") // TODO: Implement
 }
+
+type JobNotificationConfigRepository struct{}
+
+func NewJobNotificationConfigRepository(canQuery bool) repository.JobNotificationConfigRepository {
+	return &JobNotificationConfigRepository{}
+}
+
+func (n *JobNotificationConfigRepository) CreateNotificationConfig(am *models.JobNotificationConfig) (*models.JobNotificationConfig, error) {
+	panic("not implemented") // TODO: Implement
+}
+
+func (n *JobNotificationConfigRepository) ReadNotificationConfig(projID, clusterID uint, name, namespace string) (*models.JobNotificationConfig, error) {
+	panic("not implemented") // TODO: Implement
+}
+
+func (n *JobNotificationConfigRepository) UpdateNotificationConfig(am *models.JobNotificationConfig) (*models.JobNotificationConfig, error) {
+	panic("not implemented") // TODO: Implement
+}

+ 12 - 0
internal/repository/test/repository.go

@@ -30,12 +30,14 @@ type TestRepository struct {
 	githubAppOAuthIntegration repository.GithubAppOAuthIntegrationRepository
 	slackIntegration          repository.SlackIntegrationRepository
 	notificationConfig        repository.NotificationConfigRepository
+	jobNotificationConfig     repository.JobNotificationConfigRepository
 	buildEvent                repository.BuildEventRepository
 	kubeEvent                 repository.KubeEventRepository
 	projectUsage              repository.ProjectUsageRepository
 	onboarding                repository.ProjectOnboardingRepository
 	ceToken                   repository.CredentialsExchangeTokenRepository
 	buildConfig               repository.BuildConfigRepository
+	allowlist                 repository.AllowlistRepository
 }
 
 func (t *TestRepository) User() repository.UserRepository {
@@ -138,6 +140,10 @@ func (t *TestRepository) NotificationConfig() repository.NotificationConfigRepos
 	return t.notificationConfig
 }
 
+func (t *TestRepository) JobNotificationConfig() repository.JobNotificationConfigRepository {
+	return t.jobNotificationConfig
+}
+
 func (t *TestRepository) BuildEvent() repository.BuildEventRepository {
 	return t.buildEvent
 }
@@ -162,6 +168,10 @@ func (t *TestRepository) BuildConfig() repository.BuildConfigRepository {
 	return t.buildConfig
 }
 
+func (t *TestRepository) Allowlist() repository.AllowlistRepository {
+	return t.allowlist
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool, failingMethods ...string) repository.Repository {
@@ -191,11 +201,13 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		githubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(canQuery),
 		slackIntegration:          NewSlackIntegrationRepository(canQuery),
 		notificationConfig:        NewNotificationConfigRepository(canQuery),
+		jobNotificationConfig:     NewJobNotificationConfigRepository(canQuery),
 		buildEvent:                NewBuildEventRepository(canQuery),
 		kubeEvent:                 NewKubeEventRepository(canQuery),
 		projectUsage:              NewProjectUsageRepository(canQuery),
 		onboarding:                NewProjectOnboardingRepository(canQuery),
 		ceToken:                   NewCredentialsExchangeTokenRepository(canQuery),
 		buildConfig:               NewBuildConfigRepository(canQuery),
+		allowlist:                 NewAllowlistRepository(canQuery),
 	}
 }

+ 7 - 3
internal/usage/usage.go

@@ -67,14 +67,14 @@ func GetUsage(opts *GetUsageOpts) (
 		}
 	}
 
-	oldUsageCache := usageCache
+	oldUsageCache := *usageCache
 
 	usageCache.Clusters = uint(len(clusters))
 	usageCache.Users = uint(len(countedRoles))
 
 	// if the usage cache is 1 hour old, was not found, usage is currently over limit, or the clusters/users
 	// counts have changed, re-query for the usage
-	if !isCacheFound || usageCache.Is1HrOld() || isUsageExceeded(usageCache, limit) || isUsageChanged(oldUsageCache, usageCache) {
+	if !isCacheFound || usageCache.Is1HrOld() || isUsageExceeded(usageCache, limit) || isUsageChanged(&oldUsageCache, usageCache) {
 		cpu, memory, err := getResourceUsage(opts, clusters)
 
 		if err != nil {
@@ -97,10 +97,14 @@ func GetUsage(opts *GetUsageOpts) (
 
 	if !isCacheFound {
 		usageCache, err = opts.Repo.ProjectUsage().CreateProjectUsageCache(usageCache)
-	} else if isUsageChanged(oldUsageCache, usageCache) {
+	} else if isUsageChanged(&oldUsageCache, usageCache) {
 		usageCache, err = opts.Repo.ProjectUsage().UpdateProjectUsageCache(usageCache)
 	}
 
+	if err != nil {
+		return nil, nil, nil, err
+	}
+
 	return &types.ProjectUsage{
 		ResourceCPU:    usageCache.ResourceCPU,
 		ResourceMemory: usageCache.ResourceMemory,

+ 1 - 1
services/job_sidecar_container/Dockerfile

@@ -5,6 +5,6 @@ RUN apk --no-cache add procps coreutils
 
 COPY *.sh .
 
-RUN ["chmod", "+x", "./job_killer.sh", "./signal.sh", "./sidecar_killer.sh"]
+RUN ["chmod", "+x", "./job_killer.sh", "./signal.sh", "./sidecar_killer.sh", "./wait_for_job.sh"]
 
 ENTRYPOINT ["./job_killer.sh"]

+ 44 - 33
services/job_sidecar_container/job_killer.sh

@@ -40,59 +40,70 @@ graceful_shutdown() {
 
     echo "searching for process pattern: $pattern"
 
-    local target_pid_arr=$(ps x | grep -v './job_killer.sh' | grep "$pattern" | awk '{ printf "%d ", $1 }' | sort)
-    local target_pid=$target_pid_arr
+    local target_pid=$(pgrep -f $pattern -l | grep -v 'job_killer.sh' | grep -v 'wait_for_job.sh' | grep -v 'grep' | awk '{ printf "%d ", $1 }' | sort)
     local list="$target_pid"
 
-    # request graceful shutdown from target_pid
-    kill -0 ${target_pid} 2>/dev/null && kill -TERM ${target_pid}
-
-    if $kill_child_procs
-    then
-        for c in $(ps -o pid= --ppid $target_pid); do
-          # request graceful shutdown of all children, and append to process list
-          kill -0 $c 2>/dev/null && kill -TERM $c && list="$list $c" || true
-        done
-    fi
-
     if [ -n "$target_pid" ]; then
-        # schedule hard kill after timeout
-        (sleep ${timeout}; kill -9 -${target_pid} 2>/dev/null || true) &
-        local killer=${!}
-
-        # wait for processes to finish
-        for c in $list; do
-          echo "waiting for process $c"
-          tail --pid=$c -f /dev/null 
-        done
-
-        wait ${list} 2>/dev/null || true
+      # request graceful shutdown from target_pid
+      kill -0 ${target_pid} 2>/dev/null && kill -TERM ${target_pid}
+
+      if $kill_child_procs
+      then
+          for c in $(ps -o pid= --ppid $target_pid); do
+            # request graceful shutdown of all children, and append to process list
+            kill -0 $c 2>/dev/null && kill -TERM $c && list="$list $c" || true
+          done
+      fi
+
+      # schedule hard kill after timeout
+      (sleep ${timeout}; kill -9 -${target_pid} 2>/dev/null || true) &
+      local killer=${!}
+
+      # wait for processes to finish
+      for c in $list; do
+        echo "waiting for process $c"
+        tail --pid=$c -f /dev/null 
+      done
+
+      wait ${list} 2>/dev/null || true
+
+      # children exited gracefully - cancel timer
+      sleep 0.1 && kill -9 ${killer} 2>/dev/null && target_pid="" || true
+    fi
 
-        # children exited gracefully - cancel timer
-        sleep 0.1 && kill -9 ${killer} 2>/dev/null && target_pid="" || true
+    # run the sidecar killer, this will terminate any additional sidecars if necessary
+    if [ -n "$sidecar" ]; then
+        echo "killing sidecar command: $sidecar"
+        ./sidecar_killer.sh $sidecar
     fi
 
-    [ -z "$target_pid" ] && echo "Exit Gracefully (0)" && exit 0 || echo "Dirty Exit (1)" && exit 1
+    echo "Exit Gracefully (0)" && exit 0
 }
 
 trap 'graceful_shutdown $grace_period_seconds $target' SIGTERM SIGINT SIGHUP
 
+sleep 2
+
 echo "waiting for job to start..."
 
-sleep 10
+timeout 10s ./wait_for_job.sh $pattern
 
-target_pid_arr=$(ps x | grep -v './job_killer.sh' | grep "$pattern" | awk '{ printf "%d ", $1 }' | sort)
-target_pid=$target_pid_arr
+target_pid=$(pgrep -f $pattern -l | grep -v 'job_killer.sh' | grep -v 'wait_for_job.sh' | grep -v 'grep' | awk '{ printf "%d ", $1 }' | sort)
+target_pid_name=$(pgrep -f $pattern -l | grep -v 'job_killer.sh' | grep -v 'wait_for_job.sh' | grep -v 'grep')
 
 if [ -n "$target_pid" ]; then
+    echo "targeting pids $target_pid matched by $target_pid_name"
     tail --pid=$target_pid -f /dev/null &
     child=$!
 
     wait "$child"
-fi
 
-# run the sidecar killer, this will terminate any additional sidecars if necessary
-if [ -n "$sidecar" ]; then
+    graceful_shutdown $grace_period_seconds $target
+else 
+  echo "no process could be targeted within 10s, initiating shutdown"
+
+  if [ -n "$sidecar" ]; then
     echo "killing sidecar command: $sidecar"
     ./sidecar_killer.sh $sidecar
+  fi
 fi

+ 15 - 4
services/job_sidecar_container/sidecar_killer.sh

@@ -5,7 +5,18 @@
 # 
 # Usage: ./sidecar_killer.sh [target_process]
 
-target=$1
-pattern="$(printf '[%s]%s' $(echo $target | cut -c 1) $(echo $target | cut -c 2-))"
-pid=$(ps x | grep -v './sidecar_killer.sh' | grep "$pattern" | awk '{ printf "%d ", $1 }'); 
-kill -TERM $pid
+sidecar_pid=$(pgrep $1)
+
+if [ -n "$sidecar_pid" ]; then
+    kill -TERM $sidecar_pid
+
+    # schedule hard kill after 30 seconds
+    (sleep 30; kill -9 -${sidecar_pid} 2>/dev/null || true) &
+    killer=${!}
+
+    # wait for processes to finish
+    wait ${sidecar_pid} 2>/dev/null || true
+
+    # children exited gracefully - cancel timer
+    sleep 0.1 && kill -9 ${killer} 2>/dev/null && target_pid="" || true
+fi

+ 14 - 0
services/job_sidecar_container/wait_for_job.sh

@@ -0,0 +1,14 @@
+#!/bin/sh
+
+# Usage: wait_for_job.sh [process_pattern]
+#
+# This script waits for a job to be ready before exiting. 
+
+pattern=$1
+
+target_pid=$(pgrep -f $pattern -l | grep -v 'job_killer.sh' | grep -v 'wait_for_job.sh' | grep -v 'grep' | awk '{ printf "%d ", $1 }' | sort)
+
+while [ ! "$target_pid" ]; do 
+  sleep 0.1
+  target_pid=$(pgrep -f $pattern -l | grep -v 'job_killer.sh' | grep -v 'wait_for_job.sh' | grep -v 'grep' | awk '{ printf "%d ", $1 }' | sort)
+done

+ 7 - 4
services/usage/usage.go

@@ -3,6 +3,7 @@
 package usage
 
 import (
+	"fmt"
 	"sync"
 	"time"
 
@@ -102,7 +103,7 @@ func (u *UsageTracker) GetProjectUsage() (map[uint]*UsageTrackerResponse, error)
 	var mu sync.Mutex
 	var wg sync.WaitGroup
 
-	worker := func(project *models.Project) error {
+	worker := func(project *models.Project) {
 		defer wg.Done()
 
 		current, limit, cache, err := usage.GetUsage(&usage.GetUsageOpts{
@@ -113,14 +114,16 @@ func (u *UsageTracker) GetProjectUsage() (map[uint]*UsageTrackerResponse, error)
 		})
 
 		if err != nil {
-			return err
+			fmt.Printf("Project %d: error getting usage: %v\n", project.ID, err)
+			return
 		}
 
 		// get the admin emails for the project
 		roles, err := u.repo.Project().ListProjectRoles(project.ID)
 
 		if err != nil {
-			return err
+			fmt.Printf("Project %d: error getting admin emails: %v\n", project.ID, err)
+			return
 		}
 
 		adminEmails := make([]string, 0)
@@ -161,7 +164,7 @@ func (u *UsageTracker) GetProjectUsage() (map[uint]*UsageTrackerResponse, error)
 		}
 		mu.Unlock()
 
-		return nil
+		return
 	}
 
 	// iterate (count / stepSize) + 1 times using Limit and Offset