The guide is quite long! If you want to just get a proof of concept running go ahead with these instructions.
-
Make sure you have
docker
,lucky
, andup
commands.docker -v # => 19.03.12 lucky -v # => 0.24.0 up -v # => 0.1.7
-
Do the following
mkdir lhd-test cd lhd-test git clone https://github.com/KCErb/lucky-hasura-docker lucky init.custom foo_bar --api cd lucky-hasura-docker
-
Replace project variables.
sed
is different on Linux and macOS :(Linux
git grep -l 'GITLAB_USER' | xargs sed -i 's/GITLAB_USER/kcerb/g' git grep -l 'GITLAB_REPO_NAME' | xargs sed -i 's/GITLAB_REPO_NAME/foo_bar/g' git grep -l 'PROJECT_NAME' | xargs sed -i 's/PROJECT_NAME/foo_bar/g' git grep -l 'SWARM_NAME' | xargs sed -i 's/SWARM_NAME/foo_bar/g'
macOS
git grep -l 'GITLAB_USER' | xargs sed -i '' -e 's/GITLAB_USER/kcerb/g' git grep -l 'GITLAB_REPO_NAME' | xargs sed -i '' -e 's/GITLAB_REPO_NAME/foo_bar/g' git grep -l 'PROJECT_NAME' | xargs sed -i '' -e 's/PROJECT_NAME/foo_bar/g' git grep -l 'SWARM_NAME' | xargs sed -i '' -e 's/SWARM_NAME/foo_bar/g'
-
Do the following
cd .. # modify foo_bar a bit rm -rf foo_bar/script rm foo_bar/Procfile rm foo_bar/Procfile.dev echo '\nup.cache' >> foo_bar/.gitignore # rsync contents of template dir into foo_bar rsync -avr lucky-hasura-docker/proj_template/ foo_bar cd foo_bar
-
In
config/server.cr
you should see a line that starts withsettings.secret_key_base =
(line 17). Replace it withlucky_hasura_32_character_secret
. -
Now you can start developing with
script/up
-
Provision a production server somewhere.
-
Ensure that ports 80 and 443 are available to the public.
-
Save the SSL
.cert
and.key
files as/etc/certs/cloudflare.cert
and/etc/certs/cloudflare.key
. -
Create a non-root user (
lhd
for example) and make sure you can ssh into the server as that user.
-
-
Provision two deploy tokens from Gitlab
Settings > Repository
. Name onegitlab-deploy-token
, it will be used in CI so you don't need to save its username or password. Name the other whatever you like, give it at least bothread_repository
andread_registry
scopes, and be sure to copy the username and password for step 4. -
Create an account/API key on SendGrid, copy the value for the next step.
-
(Assuming you are on your production server as the
lhd
user) Create a.lhd-env
file the home dir and place the Gitlab credentials and SendGrid API key (chmod 600
this file to add a layer of security):export DEPLOY_USERNAME='gitlab+deploy-token-170337' export DEPLOY_TOKEN='8yQesUWt4MaHJ8T4d6hc' export SEND_GRID_KEY='SG.ALd_3xkHTRioKaeQ.APYYxwUdr00BJypHuximcjNBmOxET1gV8Q'
-
Add the following variables to your environment using your own values. You can use
.lhd-env
or.profile
since the CI runner uses a login shell.export APP_DOMAIN='foobar.business' export IP_ADDRESS='198.211.113.94'
-
On Gitlab also save that IP address as a variable under
Settings > CI/CD
with the keyPRODUCTION_SERVER_IP
. -
Generate a keypair (the following assumes you are doing this on your own compute NOT the production server).
ssh-keygen -t ed25519 -C “gitlab-ci@foo_bar_production” -f ~/.ssh/gitlab-ci
-
Add the private key to the CI env as
GITLAB_PRODUCTION_KEY
. -
Copy the public key to the server
ssh-copy-id -i ~/.ssh/gitlab-ci [email protected]
-
Add everything, commit, and push as below. The
sub-deploy
keyword runs thedeploy
script functions in the required order for the first push. This is all it takes to put your app online! For more details about what that includes see the full guide.git remote add origin [email protected]:KCErb/foo_bar.git git add . git commit -m "first commit [sub-deploy]" git push -u origin master
-
Once the deploy stage has passed CI, you can log in to the server and see progress with
docker service ls
. You should see1/1
for all replicas once everything is online. You can confirm this by visitinghttps://api.foobar.business/
where you'll see the Hello World message that ships with Lucky.
Security Note - You may want to rotate
the join token after the first bootstrap since it is printed to the Gitlab CI history. Read more about join tokens.
Troubleshooting - If you get Invalid memory access
at the crystal build
step of the build
stage you are bumping into a hard-to-reproduce issue that has cropped up variously across the crystal ecosystem. Just try triggering a new build and invalidating the Docker cache. Also please open an issue so that I can track the frequency of this issue.
-
(Optional) Add slack credentials for automatic slack alerts to
.lhd-env
.export SLACK_URL='https://hooks.slack.com/services/G11G430A7/AK9023U17/vaGCB6T6ZVF1HRng0WqTEaeX' export SLACK_CHANNEL='lhd-demo' export SLACK_USER='Prometheus'
-
Start the swarm (be sure that
APP_DOMAIN
is defined viasource ~/.profile
if you haven't logged out yet):source ~/.lhd-env cd ~/GITLAB_REPO_NAME/Docker/prometheus-swarm docker stack deploy -c docker-compose.yml prometheus_swarm
-
Once the services are up, log in and play around, visit
grafana.foobar.business
. The password was generated by thebootstrap
script and is stored in~/.lhd-env
asADMIN_PASSWORD
. The username isPROJECT_NAME_admin
.
-
Add this to the
call
method oftasks/create_required_seeds
(WARNING: if you don't want these in production add them tocreate_sample_seeds
instead)%w{admin buzz}.each do |name| email = name + "@foobar.business" user = UserQuery.new.email(email).first? UserBox.create &.email(email) unless user end
-
Replace
payload
variable insrc/models/user_token.cr
in theself.generate
method with# (demo ONLY, not a good way to assign roles!!!!) allowed_roles = ["user"] default_role = "user" if user.email.includes?("admin") allowed_roles << "admin" default_role = "admin" end payload = {"user_id" => user.id, "https://hasura.io/jwt/claims" => { "x-hasura-allowed-roles" => allowed_roles, "x-hasura-default-role" => default_role, "x-hasura-user-id" => user.id.to_s, } }
-
Add
user
role in Hasura dashboard localhost:9695 with permission to select their own email (screenshot in full guide). -
Add
spec/requests/graphql/users/query_spec.cr
file with contentsrequire "../../../spec_helper" require "http/client" require "json" describe "GraphQL::Users::Query" do it "admin can see all users" do admin, user = make_test_users users = graphql_request(admin) users.size.should eq 2 end it "user can see only self" do admin, user = make_test_users users = graphql_request(user) users.size.should eq 1 users.first["email"].should eq "[email protected]" end end private def make_test_users admin = UserBox.create &.email("[email protected]") user = UserBox.create &.email("[email protected]") {admin, user} end # returns [{"email" => "[email protected]"}] private def graphql_request(user) : Array(JSON::Any) client = HTTP::Client.new("foo_bar_hasura_test", 8080) client.before_request do |request| request.headers["Authorization"] = "Bearer #{UserToken.generate(user)}" end query = %({"query": "{ users { email } }"}) response = client.post("/v1/graphql", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: query) json = JSON.parse response.body data = json["data"]? data = data.should_not be_nil data["users"].as_a end
-
While we're messing with tests, this file is basically harmless but not needed, so delete
rm spec/setup/setup_database.cr
-
Run
script/test
to start a shell session in a special test environment.
When making an API call to the Lucky backend, you will probably need to setup a CORS middleware.
-
Create a file called
src/handlers/cors_handler.cr
. -
Copy and paste the following code in. Adjust the
ALLOWED_ORIGINS
to your needs.class CORSHandler include HTTP::Handler # Origins that your API allows ALLOWED_ORIGINS = [ # Allows for local development /\.lvh\.me/, /localhost/, /127\.0\.0\.1/, # Add your production domains here /foobar\.business/ ] def call(context) request_origin = context.request.headers["Origin"]? || "localhost" # Setting the CORS specific headers. # Modify according to your apps needs. context.response.headers["Access-Control-Allow-Origin"] = allowed_origin?(request_origin) ? request_origin : "" context.response.headers["Access-Control-Allow-Credentials"] = "true" context.response.headers["Access-Control-Allow-Methods"] = "POST,GET,OPTIONS" context.response.headers["Access-Control-Allow-Headers"] = "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" # If this is an OPTIONS call, respond with just the needed headers. if context.request.method == "OPTIONS" context.response.status = HTTP::Status::NO_CONTENT context.response.headers["Access-Control-Max-Age"] = "#{20.days.total_seconds.to_i}" context.response.headers["Content-Type"] = "text/plain" context.response.headers["Content-Length"] = "0" context else call_next(context) end end private def allowed_origin?(request_origin) ALLOWED_ORIGINS.find(false) do |pattern| pattern === request_origin end end end
-
Add
CORSHandler.new
to the middleware list insrc/app_server.cr
. -
Add
require "./handlers/**"
tosrc/app.cr
.
-
Uncomment the
healthcheck
lines indocker-compose.swarm.yml
under thelucky
service. -
Add the
version
route for health checks by running the following in thelucky
container.lucky gen.model Version
-
Add a
value
column to theversions
table by updatingsrc/models/version.cr
with:class Version < BaseModel table do column value : String end end
-
Update the corresponding migration by adding 1 line in the create block
add value : String
. My file looks like this:class CreateVersions::V20201010215829 < Avram::Migrator::Migration::V1 def migrate # Learn about migrations at: https://luckyframework.org/guides/database/migrations create table_for(Version) do primary_key id : Int64 add_timestamps add value : String end end def rollback drop table_for(Version) end end
-
Add a route to
GET
the current version. Put the following insrc/actions/version/get.cr
class Version::Get < ApiAction include Api::Auth::SkipRequireAuthToken get "/version" do last_version = VersionQuery.last? if last_version json({version: last_version.value}) else json({error: "Unable to reach database"}, 503) end end end
-
Add some logic to
tasks/create_required_seeds.cr
so that each time the required seeds are created we make sure the latest version number is provided:current_version = `git rev-parse --short=8 HEAD 2>&1`.rchop current_version = "pre-first-commit" unless $?.success? last_version = VersionQuery.last? version_is_same = last_version && last_version == current_version SaveVersion.create!(value: current_version) unless version_is_same
-
Migrate and seed in the
lucky
container.lucky db.migrate && lucky db.create_required_seeds
-
Test from the host
curl localhost:5000/version
Rollback and Deploy scripts are provided. To rollback to a certain image you just need to provide the tag of the image (first 8 characters of commit sha where that image was built)
script/rollback 53c086ec
If you want to fast-forward to a later commit, you can give its short-sha (first 8 characters) to the deploy script
script/deploy 8d9b3d0c
In both cases the -s
flag can be passed to signal subtractive
mode as described in the guide.