Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .controlplane/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
ARG RUBY_VERSION=3.1.2

FROM docker.io/library/node:22-bullseye-slim AS node
FROM ruby:$RUBY_VERSION

RUN apt-get update

WORKDIR /app

# Keep Node.js available both for asset compilation and for SSR runtimes that
# rely on ExecJS in production.
COPY --from=node /usr/local/ /usr/local/

# Expose Corepack-managed shims so later build steps can call yarn/pnpm
# directly during asset precompilation hooks.
RUN printf '%s\n' '#!/bin/sh' 'exec corepack yarn "$@"' > /usr/bin/yarn && \
chmod +x /usr/bin/yarn && \
printf '%s\n' '#!/bin/sh' 'exec corepack pnpm "$@"' > /usr/bin/pnpm && \
chmod +x /usr/bin/pnpm

# install ruby gems
COPY Gemfile* ./

RUN bundle config set without 'development test' && \
bundle config set with 'staging production' && \
bundle install --jobs=3 --retry=3

COPY . ./

# Install JavaScript dependencies only when the project actually has them.
RUN if [ -f package.json ]; then \
package_manager="$(node -p "require('./package.json').packageManager || ''")"; \
if [ -f yarn.lock ]; then \
if printf '%s' "$package_manager" | grep -q '^yarn@'; then \
corepack prepare "$package_manager" --activate && \
(corepack yarn install --immutable || corepack yarn install --frozen-lockfile); \
else \
npm install -g yarn && \
(yarn install --immutable || yarn install --frozen-lockfile); \
fi; \
elif [ -f pnpm-lock.yaml ]; then \
if printf '%s' "$package_manager" | grep -q '^pnpm@'; then \
corepack prepare "$package_manager" --activate && \
corepack pnpm install --frozen-lockfile; \
else \
corepack prepare pnpm@latest --activate && \
corepack pnpm install --frozen-lockfile; \
fi; \
elif [ -f package-lock.json ]; then \
npm ci; \
else \
npm install; \
fi; \
fi

ENV RAILS_ENV=production

# compiling assets requires any value for ENV of SECRET_KEY_BASE
ENV SECRET_KEY_BASE=NOT_USED_NON_BLANK

RUN rails assets:precompile

# add entrypoint
COPY .controlplane/entrypoint.sh ./
ENTRYPOINT ["/app/entrypoint.sh"]

CMD ["rails", "s"]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind Rails server to all interfaces in container

Running rails s without a bind address starts Puma on localhost by default, so the process may only listen on loopback inside the container. In Control Plane/Kubernetes-style networking, external traffic and health checks target the container/pod IP, which can leave the workload unreachable even though the process is running. Use a command that binds to 0.0.0.0 for container deployments.

Useful? React with 👍 / 👎.

45 changes: 45 additions & 0 deletions .controlplane/controlplane.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Keys beginning with "cpln_" correspond to your settings in Control Plane.
#
# This demo stores its SQLite database under `/app/db` and local Active Storage
# files under `/app/storage`, so both directories need persistent volumes.

allow_org_override_by_env: true
allow_app_override_by_env: true

aliases:
common: &common
cpln_org: my-org-staging
default_location: aws-us-east-2
setup_app_templates:
- app
- db
- storage
- rails

one_off_workload: rails
app_workloads:
- rails
additional_workloads: []

release_script: release_script.sh

stale_app_image_deployed_days: 5
image_retention_days: 7

production: &production
<<: *common
allow_org_override_by_env: false
allow_app_override_by_env: false
cpln_org: my-org-production
upstream: react-on-rails-migration-example-staging

apps:
react-on-rails-migration-example-staging:
<<: *common

react-on-rails-migration-example-review:
<<: *common
match_if_app_name_starts_with: true

react-on-rails-migration-example-production:
<<: *production
8 changes: 8 additions & 0 deletions .controlplane/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/sh
# Runs before the main command

echo " -- Preparing database"
rails db:prepare

echo " -- Finishing entrypoint.sh, executing '$@'"
exec "$@"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing set -e lets failed db:prepare start app

Medium Severity

The entrypoint.sh script is missing set -e, so if rails db:prepare fails, the script silently continues and starts the application via exec "$@" against a potentially unprepared database. The sibling release_script.sh correctly includes set -e for the same operation, suggesting this omission is unintentional.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5b81dc2. Configure here.

66 changes: 66 additions & 0 deletions .controlplane/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Control Plane Deployment Notes

This repo now includes `cpflow` scaffolding for:

- opt-in PR review apps
- automatic staging deploys from `main`
- manual promotion from staging to production

## Why This Shape

This app runs on SQLite in production and stores uploaded files on the local
disk.

The Control Plane setup mirrors that:

- `.controlplane/templates/db.yml` creates a persistent volume for `/app/db`
- `.controlplane/templates/storage.yml` creates a persistent volume for `/app/storage`
- `.controlplane/templates/rails.yml` mounts both volumes into the `rails` workload
- `.controlplane/release_script.sh` runs `bin/rails db:prepare` before deploys switch images

The generated `.controlplane/Dockerfile` now installs Node.js alongside Ruby,
auto-installs JavaScript dependencies for npm/Yarn/pnpm projects, and leaves a
callable package-manager shim in place so `assets:precompile` can invoke
`yarn` or `pnpm` again in later build steps.

## Required Runtime Secrets

Before the app will boot on Control Plane, populate `SECRET_KEY_BASE` in the
generated secret dictionaries:

- `react-on-rails-migration-example-staging-secrets`
- `react-on-rails-migration-example-review-secrets`
- `react-on-rails-migration-example-production-secrets`

`cpflow setup-app` creates those dictionaries automatically. You only need to
add a `SECRET_KEY_BASE` entry to each one before the first deploy.

## Local cpflow Flow

Typical setup:

```sh
export APP_NAME=react-on-rails-migration-example-staging

cpflow setup-app -a "$APP_NAME"
cpflow build-image -a "$APP_NAME"
cpflow deploy-image -a "$APP_NAME" --run-release-phase
cpflow open -a "$APP_NAME"
```

## GitHub Actions Variables And Secrets

Set these in GitHub before enabling the generated `cpflow-*` workflows:

- `CPLN_TOKEN_STAGING`
- `CPLN_TOKEN_PRODUCTION`
- `CPLN_ORG_STAGING`
- `CPLN_ORG_PRODUCTION`
- `STAGING_APP_NAME=react-on-rails-migration-example-staging`
- `PRODUCTION_APP_NAME=react-on-rails-migration-example-production`
- `REVIEW_APP_PREFIX=react-on-rails-migration-example-review`

Optional:

- `STAGING_APP_BRANCH=main`
- `PRIMARY_WORKLOAD=rails`
5 changes: 5 additions & 0 deletions .controlplane/release_script.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/sh
set -e

mkdir -p db storage
./bin/rails db:prepare
15 changes: 15 additions & 0 deletions .controlplane/templates/app.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
kind: gvc
name: {{APP_NAME}}
spec:
env:
- name: RAILS_ENV
value: production
- name: RAILS_LOG_TO_STDOUT
value: "true"
- name: RAILS_SERVE_STATIC_FILES
value: "true"
- name: SECRET_KEY_BASE
value: cpln://secret/{{APP_SECRETS}}.SECRET_KEY_BASE
staticPlacement:
locationLinks:
- {{APP_LOCATION_LINK}}
6 changes: 6 additions & 0 deletions .controlplane/templates/db.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
kind: volumeset
name: app-db
spec:
fileSystemType: ext4
initialCapacity: 5
performanceClass: general-purpose-ssd
32 changes: 32 additions & 0 deletions .controlplane/templates/rails.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
kind: workload
name: rails
spec:
type: standard
containers:
- name: rails
cpu: 300m
inheritEnv: true
image: {{APP_IMAGE_LINK}}
memory: 512Mi
ports:
- number: 3000
protocol: http
volumes:
- path: /app/db
recoveryPolicy: retain
uri: cpln://volumeset/app-db
- path: /app/storage
recoveryPolicy: retain
uri: cpln://volumeset/app-storage
defaultOptions:
autoscaling:
minScale: 1
maxScale: 1
capacityAI: false
timeoutSeconds: 60
firewallConfig:
external:
inboundAllowCIDR:
- 0.0.0.0/0
outboundAllowCIDR:
- 0.0.0.0/0
6 changes: 6 additions & 0 deletions .controlplane/templates/storage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
kind: volumeset
name: app-storage
spec:
fileSystemType: ext4
initialCapacity: 10
performanceClass: general-purpose-ssd
70 changes: 70 additions & 0 deletions .github/actions/cpflow-build-docker-image/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: Build Docker Image
description: Builds and pushes the app image for a Control Plane workload

inputs:
app_name:
description: Name of the application
required: true
org:
description: Control Plane organization name
required: true
commit:
description: Commit SHA to tag the image with
required: true
pr_number:
description: Pull request number for status messaging
required: false
docker_build_extra_args:
description: Optional newline-delimited extra arguments passed through to docker build
required: false
docker_build_ssh_key:
description: Optional private SSH key used for Docker builds that fetch private dependencies with RUN --mount=type=ssh
required: false

outputs:
image_tag:
description: Fully qualified image tag
value: ${{ steps.build.outputs.image_tag }}

runs:
using: composite
steps:
- name: Build Docker image
id: build
shell: bash
run: |
set -euo pipefail

PR_INFO=""
docker_build_args=()

if [[ -n "${{ inputs.pr_number }}" ]]; then
PR_INFO=" for PR #${{ inputs.pr_number }}"
fi

if [[ -n "${{ inputs.docker_build_extra_args }}" ]]; then
while IFS= read -r arg; do
arg="${arg%$'\r'}"
[[ -n "${arg}" ]] || continue
docker_build_args+=("${arg}")
done <<< "${{ inputs.docker_build_extra_args }}"
fi

if [[ -n "${{ inputs.docker_build_ssh_key }}" ]]; then
mkdir -p ~/.ssh
chmod 700 ~/.ssh
ssh-keyscan -H github.com >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts

eval "$(ssh-agent -s)"
trap 'ssh-agent -k >/dev/null' EXIT
ssh-add - <<< "${{ inputs.docker_build_ssh_key }}"
docker_build_args+=("--ssh default")
fi

echo "🏗️ Building Docker image${PR_INFO} (commit ${{ inputs.commit }})..."
cpflow build-image -a "${{ inputs.app_name }}" --commit="${{ inputs.commit }}" --org="${{ inputs.org }}" "${docker_build_args[@]}"

image_tag="${{ inputs.org }}/${{ inputs.app_name }}:${{ inputs.commit }}"
echo "image_tag=${image_tag}" >> "$GITHUB_OUTPUT"
echo "✅ Docker image build successful${PR_INFO} (commit ${{ inputs.commit }})"
24 changes: 24 additions & 0 deletions .github/actions/cpflow-delete-control-plane-app/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Delete Control Plane App
description: Deletes a Control Plane app and all associated resources

inputs:
app_name:
description: Name of the application to delete
required: true
cpln_org:
description: Control Plane organization name
required: true
review_app_prefix:
description: Prefix used for review app names
required: true

runs:
using: composite
steps:
- name: Delete application
shell: bash
run: ${{ github.action_path }}/delete-app.sh
env:
APP_NAME: ${{ inputs.app_name }}
CPLN_ORG: ${{ inputs.cpln_org }}
REVIEW_APP_PREFIX: ${{ inputs.review_app_prefix }}
37 changes: 37 additions & 0 deletions .github/actions/cpflow-delete-control-plane-app/delete-app.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/bin/bash

set -euo pipefail

: "${APP_NAME:?APP_NAME environment variable is required}"
: "${CPLN_ORG:?CPLN_ORG environment variable is required}"
: "${REVIEW_APP_PREFIX:?REVIEW_APP_PREFIX environment variable is required}"

expected_prefix="${REVIEW_APP_PREFIX}-"
if [[ "$APP_NAME" != "${expected_prefix}"* ]]; then
echo "❌ ERROR: refusing to delete an app outside the review app prefix" >&2
echo "App name: $APP_NAME" >&2
echo "Expected prefix: ${expected_prefix}" >&2
exit 1
fi

echo "🔍 Checking if application exists: $APP_NAME"
exists_output=""
if ! exists_output="$(cpflow exists -a "$APP_NAME" --org "$CPLN_ORG" 2>&1)"; then
if [[ -z "$exists_output" ]]; then
echo "⚠️ Application does not exist: $APP_NAME"
exit 0
fi

echo "❌ ERROR: failed to determine whether application exists: $APP_NAME" >&2
printf '%s\n' "$exists_output" >&2
exit 1
fi

if [[ -n "$exists_output" ]]; then
printf '%s\n' "$exists_output"
fi

echo "🗑️ Deleting application: $APP_NAME"
cpflow delete -a "$APP_NAME" --org "$CPLN_ORG" --yes

echo "✅ Successfully deleted application: $APP_NAME"
Loading