gemstack 0.3.4 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/lib/gemstack/cli/deploy_generator.rb +43 -14
- data/lib/gemstack/cli/doctor/secret_checks.rb +49 -0
- data/lib/gemstack/cli/doctor.rb +3 -17
- data/lib/gemstack/cli/helpers.rb +88 -0
- data/lib/gemstack/cli/update_generator.rb +131 -0
- data/lib/gemstack/cli.rb +18 -78
- data/lib/gemstack/version.rb +1 -1
- data/templates/deploy/Dockerfile.tt +42 -40
- data/templates/deploy/bin/docker-entrypoint.tt +21 -0
- data/templates/deploy/config/deploy.yml.tt +156 -0
- data/templates/deploy/dot_dockerignore +1 -0
- data/templates/deploy/dot_kamal/secrets.tt +18 -0
- data/templates/frontend/next.config.ts +5 -2
- metadata +9 -6
- data/templates/deploy/Caddyfile.tt +0 -19
- data/templates/deploy/Procfile.tt +0 -6
- data/templates/deploy/compose.yaml.tt +0 -129
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 30c68d4db704acae059c66f9847b0158ad178ebd7da1368c37056e5b93232306
|
|
4
|
+
data.tar.gz: 2b65b33d1e415354038e4f4bac03556196af8d95fc01f144c7a53e97d95527b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9939a8e3bd4fb59fc2794ae86b66fc05751f8aa6463a4cd637cc8cd575ba300c2d824c02a4a245cda0a3b666e0107ab383f7e5a16499dffafda152e433b02448
|
|
7
|
+
data.tar.gz: 389c35595446d6c96ac811be7a978e019bb310e7cb03570b7e04033e1f2a3fda682cbecb1b1e513409933b18b5d6322375175ddc324ec23270a6a78308676af7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.5
|
|
4
|
+
|
|
5
|
+
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
6
|
+
|
|
7
|
+
## 0.3.4
|
|
8
|
+
|
|
9
|
+
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
10
|
+
|
|
3
11
|
## 0.3.3
|
|
4
12
|
|
|
5
13
|
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
@@ -2,16 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
module GemStack
|
|
4
4
|
class CLI < Thor
|
|
5
|
-
# `gemstack generate deploy` —
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# `gemstack generate deploy` — a Kamal setup: one Dockerfile image for every
|
|
6
|
+
# process, config/deploy.yml (web, api and jobs roles; kamal-proxy routes /api
|
|
7
|
+
# to Ruby and handles HTTPS; database accessories), .kamal/secrets,
|
|
8
|
+
# bin/docker-entrypoint and .dockerignore. Nothing is deployed anywhere.
|
|
8
9
|
class DeployGenerator < Generator
|
|
9
10
|
def initialize(root:, output: $stdout, force: false)
|
|
10
11
|
super(output: output, force: force)
|
|
11
12
|
@root = root
|
|
12
13
|
end
|
|
13
14
|
|
|
15
|
+
KAMAL_GEM = %(gem "kamal", require: false, group: :development # bundle exec kamal deploy (config/deploy.yml)\n)
|
|
16
|
+
# The image runs Next.js's standalone server (node frontend/server.js); the
|
|
17
|
+
# Dockerfile asks for that build with GEMSTACK_NEXT_OUTPUT=standalone.
|
|
18
|
+
STANDALONE = %( output: process.env.GEMSTACK_NEXT_OUTPUT === "standalone" ? "standalone" : undefined,\n)
|
|
19
|
+
|
|
14
20
|
def app_name = File.basename(@root).downcase.gsub(/[^a-z0-9_-]/, "-")
|
|
21
|
+
# Kamal service and container names (also DNS names on the server).
|
|
22
|
+
def service = app_name.tr("_", "-")
|
|
23
|
+
def database_name = "#{app_name.tr("-", "_")}_production"
|
|
15
24
|
def frontend? = File.file?(File.join(@root, "frontend/package.json"))
|
|
16
25
|
|
|
17
26
|
# A worker process is needed for background jobs (and auth's emails).
|
|
@@ -50,16 +59,10 @@ module GemStack
|
|
|
50
59
|
end
|
|
51
60
|
|
|
52
61
|
def runtime_packages
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def database_url
|
|
58
|
-
case database_adapter
|
|
59
|
-
when "sqlite3" then "sqlite3:/data/production.sqlite3"
|
|
60
|
-
when "postgresql" then "postgres://app:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/app"
|
|
61
|
-
else "#{database_adapter}://app:${MYSQL_PASSWORD:?set MYSQL_PASSWORD}@db:3306/app"
|
|
62
|
-
end
|
|
62
|
+
packages = %w[libyaml-0-2 curl] +
|
|
63
|
+
{ "postgresql" => ["libpq5"], "mysql2" => ["libmariadb3"] }.fetch(database_adapter, [])
|
|
64
|
+
packages << "libstdc++6" if frontend? # Node.js
|
|
65
|
+
packages.join(" ")
|
|
63
66
|
end
|
|
64
67
|
|
|
65
68
|
def ruby_version
|
|
@@ -77,10 +80,12 @@ module GemStack
|
|
|
77
80
|
template_files("deploy", override_root: @root).sort.each do |rel, source|
|
|
78
81
|
content = File.read(source)
|
|
79
82
|
content = render(content, source) if rel.end_with?(".tt")
|
|
80
|
-
write(File.join(@root, output_path(rel.delete_suffix(".tt"))), content)
|
|
83
|
+
write(File.join(@root, output_path(rel.delete_suffix(".tt"))), content, mode: File.stat(source).mode)
|
|
81
84
|
end
|
|
82
85
|
keep = File.join(@root, "vendor/.keep")
|
|
83
86
|
write(keep, "") unless File.exist?(keep) # the Dockerfile copies vendor/ (e.g. vendor/cache)
|
|
87
|
+
add_kamal_gem
|
|
88
|
+
enable_standalone_frontend if frontend?
|
|
84
89
|
warn_about_local_gems
|
|
85
90
|
self
|
|
86
91
|
end
|
|
@@ -89,6 +94,30 @@ module GemStack
|
|
|
89
94
|
|
|
90
95
|
def gemfile = @gemfile ||= File.read(File.join(@root, "Gemfile"))
|
|
91
96
|
|
|
97
|
+
def add_kamal_gem
|
|
98
|
+
path = File.join(@root, "Gemfile")
|
|
99
|
+
return status("identical", path, "kamal") if gemfile.match?(/^\s*gem ["']kamal["']/)
|
|
100
|
+
|
|
101
|
+
File.write(path, "#{gemfile.chomp}\n\n#{KAMAL_GEM}")
|
|
102
|
+
status("gem", path, "kamal — run bundle install")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def enable_standalone_frontend
|
|
106
|
+
path = File.join(@root, "frontend/next.config.ts")
|
|
107
|
+
return unless File.file?(path)
|
|
108
|
+
|
|
109
|
+
config = File.read(path)
|
|
110
|
+
return status("identical", path, "standalone output") if config.include?("GEMSTACK_NEXT_OUTPUT")
|
|
111
|
+
|
|
112
|
+
updated = config.sub(/^const nextConfig: NextConfig = \{\n/) { |open| open + STANDALONE }
|
|
113
|
+
if updated == config
|
|
114
|
+
return status("warning", path, "add `output: \"standalone\"` for the Docker build (docs/deployment.md)")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
File.write(path, updated)
|
|
118
|
+
status("update", path, "standalone output for the Docker build")
|
|
119
|
+
end
|
|
120
|
+
|
|
92
121
|
# Apps generated from a GemStack checkout point at it with an absolute
|
|
93
122
|
# path, which doesn't exist inside the image.
|
|
94
123
|
def warn_about_local_gems
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
class Doctor
|
|
6
|
+
# Secrets must never be committed: no secret files tracked by git, and
|
|
7
|
+
# .kamal/secrets (which is committed) may only reference secrets
|
|
8
|
+
# ($NAME, $(kamal secrets fetch …)); a value written in place is a leak.
|
|
9
|
+
module SecretChecks
|
|
10
|
+
def check_git_secrets
|
|
11
|
+
return unless File.directory?(path(".git"))
|
|
12
|
+
|
|
13
|
+
out, status = @run.call("git", "-C", @root, "ls-files", "--", ".env", ".env.*", "*.pem", "*.key",
|
|
14
|
+
"tmp/*_secret")
|
|
15
|
+
return unless status.success?
|
|
16
|
+
|
|
17
|
+
tracked = out.lines.map(&:strip).reject { |file| file.empty? || file.end_with?(".example") }
|
|
18
|
+
if tracked.empty?
|
|
19
|
+
pass("no secret files tracked by git")
|
|
20
|
+
else
|
|
21
|
+
problem("secret files tracked by git: #{tracked.join(", ")}",
|
|
22
|
+
"remove them from git (git rm --cached …), rotate every secret they contain, and ask whoever " \
|
|
23
|
+
"manages your infrastructure to check where the repository was shared")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def check_kamal_secrets
|
|
28
|
+
content = read(".kamal/secrets")
|
|
29
|
+
return unless content
|
|
30
|
+
|
|
31
|
+
literal = content.lines.filter_map do |line|
|
|
32
|
+
name, value = line.strip.split("=", 2)
|
|
33
|
+
next if name.nil? || name.start_with?("#") || value.nil?
|
|
34
|
+
|
|
35
|
+
value = value.strip.delete_prefix('"').delete_suffix('"').delete_prefix("'").delete_suffix("'")
|
|
36
|
+
name unless value.empty? || value.include?("$")
|
|
37
|
+
end
|
|
38
|
+
if literal.empty?
|
|
39
|
+
pass(".kamal/secrets only references secrets")
|
|
40
|
+
else
|
|
41
|
+
problem(".kamal/secrets contains secret values: #{literal.join(", ")}",
|
|
42
|
+
"replace each with a reference (NAME=$NAME, or $(kamal secrets fetch …)), rotate those " \
|
|
43
|
+
"secrets, and ask whoever manages your infrastructure to check where the repository was shared")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/gemstack/cli/doctor.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "open3"
|
|
|
4
4
|
require "socket"
|
|
5
5
|
require "gemstack/dev"
|
|
6
6
|
require_relative "doctor/upgrade_check"
|
|
7
|
+
require_relative "doctor/secret_checks"
|
|
7
8
|
|
|
8
9
|
module GemStack
|
|
9
10
|
class CLI < Thor
|
|
@@ -13,6 +14,7 @@ module GemStack
|
|
|
13
14
|
# deploy needs.
|
|
14
15
|
class Doctor
|
|
15
16
|
include UpgradeCheck
|
|
17
|
+
include SecretChecks
|
|
16
18
|
|
|
17
19
|
Result = Struct.new(:status, :title, :hint)
|
|
18
20
|
|
|
@@ -33,6 +35,7 @@ module GemStack
|
|
|
33
35
|
check_node
|
|
34
36
|
check_frontend_dependencies
|
|
35
37
|
check_git_secrets
|
|
38
|
+
check_kamal_secrets
|
|
36
39
|
check_gemstack_upgrade
|
|
37
40
|
booted = check_boot
|
|
38
41
|
if booted
|
|
@@ -98,23 +101,6 @@ module GemStack
|
|
|
98
101
|
end
|
|
99
102
|
|
|
100
103
|
# Secrets committed to git stay in its history (and in every clone).
|
|
101
|
-
def check_git_secrets
|
|
102
|
-
return unless File.directory?(path(".git"))
|
|
103
|
-
|
|
104
|
-
out, status = @run.call("git", "-C", @root, "ls-files", "--", ".env", ".env.*", "*.pem", "*.key",
|
|
105
|
-
"tmp/*_secret")
|
|
106
|
-
return unless status.success?
|
|
107
|
-
|
|
108
|
-
tracked = out.lines.map(&:strip).reject { |file| file.empty? || file.end_with?(".example") }
|
|
109
|
-
if tracked.empty?
|
|
110
|
-
pass("no secret files tracked by git")
|
|
111
|
-
else
|
|
112
|
-
problem("secret files tracked by git: #{tracked.join(", ")}",
|
|
113
|
-
"remove them from git (git rm --cached …), rotate every secret they contain, and ask whoever " \
|
|
114
|
-
"manages your infrastructure to check where the repository was shared")
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
|
|
118
104
|
def check_boot
|
|
119
105
|
ENV["GEMSTACK_ENV"] = "production" if @production
|
|
120
106
|
ENV["GEMSTACK_ENV"] ||= "development"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
# The `generate` command's helpers (kept out of cli.rb for length); nested in
|
|
6
|
+
# CLI so the generator classes (ResourceSpec, ResourceGenerator, …) resolve.
|
|
7
|
+
module Helpers
|
|
8
|
+
# -e ENV, else GEMSTACK_ENV, else development.
|
|
9
|
+
def use_environment!
|
|
10
|
+
ENV["GEMSTACK_ENV"] = options[:environment] || ENV["GEMSTACK_ENV"] || "development"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def generate_model(root, name, args)
|
|
14
|
+
abort("Usage: gemstack generate model NAME field:type ...") unless name && !args.empty?
|
|
15
|
+
spec = ResourceSpec.new(name, args)
|
|
16
|
+
ResourceGenerator.new(
|
|
17
|
+
spec,
|
|
18
|
+
root: root,
|
|
19
|
+
parts: %i[migration model serializer],
|
|
20
|
+
tests: !options[:skip_tests],
|
|
21
|
+
force: options[:force]
|
|
22
|
+
).run
|
|
23
|
+
say("\nNext: gemstack db:migrate")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def generate_job(root, name, args)
|
|
27
|
+
abort("Usage: gemstack generate job NAME [QUEUE]") unless name
|
|
28
|
+
JobGenerator.new(name, queue: args.first, root: root, force: options[:force]).run
|
|
29
|
+
say("\nNext: gemstack db:migrate (first job only) · #{Inflector.camelize(name)}.perform_later(...)")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def generate_policy(root, name)
|
|
33
|
+
abort("Usage: gemstack generate policy MODEL") unless name
|
|
34
|
+
PolicyGenerator.new(name, root: root, force: options[:force]).run
|
|
35
|
+
say("\nNext: authorize!(record) and policy_scope(#{Inflector.camelize(name)}) in controllers")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def generate_resource(root, name, args)
|
|
39
|
+
abort("Usage: gemstack generate resource NAME field:type ...") unless name
|
|
40
|
+
abort("--api-only and --frontend-only can't be combined") if options[:api_only] && options[:frontend_only]
|
|
41
|
+
interactive = args.empty? && $stdin.tty?
|
|
42
|
+
args = ask("Fields (e.g. name:string price:decimal description:text:optional):").split if interactive
|
|
43
|
+
abort("Give at least one field, e.g. `gemstack generate resource #{name} name:string`") if args.empty?
|
|
44
|
+
|
|
45
|
+
spec = ResourceSpec.new(name, args, actions: resource_actions(interactive))
|
|
46
|
+
parts = resource_parts(root, interactive)
|
|
47
|
+
tests = !options[:skip_tests] && (!interactive || yes?("Generate tests? [Y/n]"))
|
|
48
|
+
ResourceGenerator.new(spec, root: root, parts: parts, tests: tests, force: options[:force]).run
|
|
49
|
+
refresh_contract(root) unless options[:skip_contract]
|
|
50
|
+
page = " · open http://localhost:3000/#{spec.url_segment}" if parts.include?(:frontend)
|
|
51
|
+
say("\nNext: gemstack db:migrate#{page}")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def resource_parts(root, interactive)
|
|
55
|
+
parts = options[:frontend_only] ? [] : %i[migration model serializer controller]
|
|
56
|
+
frontend = File.directory?(File.join(root, "frontend")) && !options[:api_only]
|
|
57
|
+
frontend &&= yes?("Generate Next.js pages? [Y/n]") if interactive
|
|
58
|
+
frontend ? parts + [:frontend] : parts
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def resource_actions(interactive)
|
|
62
|
+
return options[:actions].split(",").map(&:strip) if options[:actions]
|
|
63
|
+
return ResourceSpec::REST_ACTIONS if !interactive || yes?("Generate the full CRUD API? [Y/n]")
|
|
64
|
+
|
|
65
|
+
ask("Actions (comma-separated from #{ResourceSpec::REST_ACTIONS.join(",")}):").split(",").map(&:strip)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Thor's yes? only accepts y/yes; treat an empty answer as "yes".
|
|
69
|
+
def yes?(question)
|
|
70
|
+
answer = ask(question).strip.downcase
|
|
71
|
+
answer.empty? || %w[y yes].include?(answer)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Regenerates TypeScript types/clients in the app's own bundle.
|
|
75
|
+
def refresh_contract(root)
|
|
76
|
+
say(" #{"run".rjust(9)} gemstack contract")
|
|
77
|
+
ok = (defined?(Bundler) ? Bundler.with_unbundled_env { contract_command(root) } : contract_command(root))
|
|
78
|
+
return if ok
|
|
79
|
+
|
|
80
|
+
say(" #{"warning".rjust(9)} contract not generated — run `gemstack contract` once the database is reachable")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def contract_command(root)
|
|
84
|
+
system("bundle", "exec", "gemstack", "contract", "--quiet", chdir: root)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
|
|
7
|
+
module GemStack
|
|
8
|
+
class CLI < Thor
|
|
9
|
+
# `gemstack update [VERSION]` — moves an app to the latest GemStack release
|
|
10
|
+
# (or VERSION): rewrites every GemStack gem's constraint in the Gemfile to
|
|
11
|
+
# "~> VERSION" and runs `bundle update` for them, together (the gems pin each
|
|
12
|
+
# other's exact version). Apps that use a GemStack checkout (`path "…"`)
|
|
13
|
+
# update the checkout instead.
|
|
14
|
+
class UpdateGenerator
|
|
15
|
+
# GemStack's gems, as an app's Gemfile lists them (gemstack-cli comes with gemstack).
|
|
16
|
+
GEMS = %w[gemstack gemstack-auth gemstack-realtime].freeze
|
|
17
|
+
VERSION_FORMAT = /\A\d+\.\d+\.\d+\z/
|
|
18
|
+
LATEST_URL = "https://rubygems.org/api/v1/versions/gemstack/latest.json"
|
|
19
|
+
|
|
20
|
+
def initialize(root:, version: nil, output: $stdout, latest: nil, bundle: nil)
|
|
21
|
+
@root = root
|
|
22
|
+
@version = version
|
|
23
|
+
@output = output
|
|
24
|
+
@latest = latest || -> { latest_release }
|
|
25
|
+
@bundle = bundle || ->(gems) { Bundler.with_unbundled_env { system("bundle", "update", *gems, chdir: @root) } }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run
|
|
29
|
+
gemfile = read("Gemfile") or return failure("no Gemfile in #{@root}")
|
|
30
|
+
return checkout(gemfile) if gemfile.match?(/^\s*path\s+["'][^"']+["']\s+do\b/)
|
|
31
|
+
|
|
32
|
+
listed = GEMS.select { |name| gemfile.match?(gem_line(name)) }
|
|
33
|
+
return failure(%(no `gem "gemstack"` in the Gemfile)) unless listed.include?("gemstack")
|
|
34
|
+
|
|
35
|
+
target = @version || @latest.call
|
|
36
|
+
return failure("#{target.inspect} is not a version (e.g. 0.3.5)") unless target.to_s.match?(VERSION_FORMAT)
|
|
37
|
+
|
|
38
|
+
current = locked_version
|
|
39
|
+
if current == target
|
|
40
|
+
@output.puts("GemStack is already #{target}.")
|
|
41
|
+
return true
|
|
42
|
+
end
|
|
43
|
+
if current && Gem::Version.new(target) < Gem::Version.new(current) && !@version
|
|
44
|
+
@output.puts("GemStack #{current} is newer than the latest release (#{target}); nothing to do.")
|
|
45
|
+
return true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
update(gemfile, listed, current, target)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def update(gemfile, listed, current, target)
|
|
54
|
+
@output.puts("Updating GemStack #{current || "(not installed yet)"} → #{target}")
|
|
55
|
+
updated = listed.reduce(gemfile) { |text, name| constrain(text, name, target) }
|
|
56
|
+
File.write(File.join(@root, "Gemfile"), updated)
|
|
57
|
+
listed.each { |name| @output.puts(%( Gemfile gem "#{name}", "~> #{target}")) }
|
|
58
|
+
|
|
59
|
+
gems = (listed + ["gemstack-cli"]).uniq
|
|
60
|
+
@output.puts(" run bundle update #{gems.join(" ")}")
|
|
61
|
+
bundled = begin
|
|
62
|
+
@bundle.call(gems)
|
|
63
|
+
rescue StandardError
|
|
64
|
+
File.write(File.join(@root, "Gemfile"), gemfile) # never leave a half-done update behind
|
|
65
|
+
raise
|
|
66
|
+
end
|
|
67
|
+
return bundle_failed(gemfile) unless bundled
|
|
68
|
+
|
|
69
|
+
installed = locked_version
|
|
70
|
+
unless installed == target
|
|
71
|
+
return failure("bundle update finished but Gemfile.lock has gemstack #{installed.inspect}, not #{target}")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@output.puts(<<~DONE)
|
|
75
|
+
|
|
76
|
+
GemStack #{target} is installed. Next:
|
|
77
|
+
- read what changed: https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md
|
|
78
|
+
- gemstack doctor
|
|
79
|
+
- gemstack test
|
|
80
|
+
DONE
|
|
81
|
+
true
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# `gem "name"` with any (or no) version requirement → `gem "name", "~> target"`,
|
|
85
|
+
# keeping other options (require:, group:) and trailing comments.
|
|
86
|
+
def constrain(text, name, target)
|
|
87
|
+
text.gsub(/^(\s*gem\s+["']#{Regexp.escape(name)}["'])((?:\s*,\s*["'][^"']*["'])*)/) do
|
|
88
|
+
%(#{Regexp.last_match(1)}, "~> #{target}")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def gem_line(name) = /^\s*gem\s+["']#{Regexp.escape(name)}["']/
|
|
93
|
+
|
|
94
|
+
def checkout(gemfile)
|
|
95
|
+
path = gemfile[/^\s*path\s+["']([^"']+)["']/, 1]
|
|
96
|
+
@output.puts("This app uses GemStack from a checkout (#{path}): update it there, e.g.")
|
|
97
|
+
@output.puts(" git -C #{path.delete_suffix("/gems")} pull")
|
|
98
|
+
true
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def bundle_failed(original)
|
|
102
|
+
File.write(File.join(@root, "Gemfile"), original)
|
|
103
|
+
failure("bundle update failed — the Gemfile is unchanged; see Bundler's message above")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def locked_version
|
|
107
|
+
read("Gemfile.lock")&.[](/^ gemstack \(([^)]+)\)/, 1)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def latest_release
|
|
111
|
+
response = Net::HTTP.get_response(URI(LATEST_URL))
|
|
112
|
+
raise Thor::Error, "rubygems.org answered #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
113
|
+
|
|
114
|
+
JSON.parse(response.body).fetch("version")
|
|
115
|
+
rescue SocketError, SystemCallError, Timeout::Error, JSON::ParserError => e
|
|
116
|
+
raise Thor::Error, "Couldn't look up the latest GemStack on rubygems.org (#{e.class}). " \
|
|
117
|
+
"Pass a version: gemstack update 0.3.5"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def read(relative)
|
|
121
|
+
path = File.join(@root, relative)
|
|
122
|
+
File.read(path) if File.file?(path)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def failure(message)
|
|
126
|
+
@output.puts("✗ #{message}")
|
|
127
|
+
false
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
data/lib/gemstack/cli.rb
CHANGED
|
@@ -24,9 +24,11 @@ module GemStack
|
|
|
24
24
|
require_relative "cli/doctor"
|
|
25
25
|
require_relative "cli/deploy_generator"
|
|
26
26
|
require_relative "cli/add_generator"
|
|
27
|
+
require_relative "cli/update_generator"
|
|
27
28
|
require_relative "cli/commands/db"
|
|
28
29
|
require_relative "cli/commands/jobs"
|
|
29
30
|
require_relative "cli/console_methods"
|
|
31
|
+
require_relative "cli/helpers"
|
|
30
32
|
|
|
31
33
|
# Commands that need the application's bundle (see Project.ensure_bundle!).
|
|
32
34
|
class << self
|
|
@@ -51,7 +53,6 @@ module GemStack
|
|
|
51
53
|
desc "new NAME", "Create a new GemStack application (Ruby API + Next.js frontend)"
|
|
52
54
|
long_desc <<~DESC
|
|
53
55
|
Creates NAME/ with a Ruby API, a Next.js + TypeScript frontend and no business resources.
|
|
54
|
-
|
|
55
56
|
Then: cd NAME && gemstack dev
|
|
56
57
|
DESC
|
|
57
58
|
method_option :skip_install, type: :boolean, default: false, desc: "Don't run bundle install / npm install"
|
|
@@ -153,7 +154,7 @@ module GemStack
|
|
|
153
154
|
app/policies/order_policy.rb + test (needs gemstack add auth)
|
|
154
155
|
|
|
155
156
|
gemstack generate deploy
|
|
156
|
-
Dockerfile
|
|
157
|
+
Kamal: Dockerfile, config/deploy.yml, .kamal/secrets, bin/docker-entrypoint, .dockerignore
|
|
157
158
|
|
|
158
159
|
Field syntax: name:type[:optional][:unique][:index]. Types: #{ResourceSpec::TYPES.join(", ")}.
|
|
159
160
|
Fields are required unless marked :optional.
|
|
@@ -180,7 +181,8 @@ module GemStack
|
|
|
180
181
|
when "policy" then generate_policy(root, name)
|
|
181
182
|
when "deploy"
|
|
182
183
|
DeployGenerator.new(root: root, force: options[:force]).run
|
|
183
|
-
say("\nNext:
|
|
184
|
+
say("\nNext: bundle install, edit the CHANGE lines in config/deploy.yml, then " \
|
|
185
|
+
"bundle exec kamal setup — docs/deployment.md")
|
|
184
186
|
when nil then abort("Usage: gemstack generate GENERATOR NAME. Generators: #{GENERATORS}")
|
|
185
187
|
else abort("Unknown generator #{generator.inspect}. Available: #{GENERATORS}")
|
|
186
188
|
end
|
|
@@ -196,12 +198,10 @@ module GemStack
|
|
|
196
198
|
long_desc <<~DESC
|
|
197
199
|
gemstack add realtime
|
|
198
200
|
Adds gemstack-realtime, config/channels.rb, frontend/lib/gemstack/realtime.ts and test helpers.
|
|
199
|
-
|
|
200
201
|
gemstack add auth
|
|
201
202
|
Sign up, log in/out, password reset and email verification, API tokens and policies:
|
|
202
203
|
migrations (users, sessions, auth_tokens), User model, controllers, routes, AuthMailer,
|
|
203
204
|
tests and Next.js pages (/login, /signup, /forgot-password, /reset-password, /verify-email, /account).
|
|
204
|
-
|
|
205
205
|
gemstack add storage
|
|
206
206
|
File uploads straight from the browser to disk (development) or S3: config, uploads
|
|
207
207
|
controller, routes and frontend/lib/upload.ts.
|
|
@@ -219,8 +219,7 @@ module GemStack
|
|
|
219
219
|
desc "doctor", "Check that this app can run (Ruby, Node, database, migrations, contract…) and how to fix it"
|
|
220
220
|
long_desc <<~DESC
|
|
221
221
|
gemstack doctor checks for development
|
|
222
|
-
gemstack doctor --production also checks the settings a deploy needs (run it with the production
|
|
223
|
-
environment variables: SECRET_KEY_BASE, DATABASE_URL, SMTP_URL…)
|
|
222
|
+
gemstack doctor --production also checks the settings a deploy needs (run it with the production environment variables: SECRET_KEY_BASE, DATABASE_URL, SMTP_URL…)
|
|
224
223
|
DESC
|
|
225
224
|
method_option :production, type: :boolean, default: false
|
|
226
225
|
def doctor
|
|
@@ -231,78 +230,19 @@ module GemStack
|
|
|
231
230
|
desc "version", "Print the GemStack version"
|
|
232
231
|
def version = say("GemStack #{GemStack::VERSION}")
|
|
233
232
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
abort("Usage: gemstack generate model NAME field:type ...") unless name && !args.empty?
|
|
240
|
-
spec = ResourceSpec.new(name, args)
|
|
241
|
-
ResourceGenerator.new(spec, root: root, parts: %i[migration model serializer], tests: !options[:skip_tests],
|
|
242
|
-
force: options[:force]).run
|
|
243
|
-
say("\nNext: gemstack db:migrate")
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
def generate_job(root, name, args)
|
|
247
|
-
abort("Usage: gemstack generate job NAME [QUEUE]") unless name
|
|
248
|
-
JobGenerator.new(name, queue: args.first, root: root, force: options[:force]).run
|
|
249
|
-
say("\nNext: gemstack db:migrate (first job only) · #{Inflector.camelize(name)}.perform_later(...)")
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
def generate_policy(root, name)
|
|
253
|
-
abort("Usage: gemstack generate policy MODEL") unless name
|
|
254
|
-
PolicyGenerator.new(name, root: root, force: options[:force]).run
|
|
255
|
-
say("\nNext: authorize!(record) and policy_scope(#{Inflector.camelize(name)}) in controllers")
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
def generate_resource(root, name, args)
|
|
259
|
-
abort("Usage: gemstack generate resource NAME field:type ...") unless name
|
|
260
|
-
abort("--api-only and --frontend-only can't be combined") if options[:api_only] && options[:frontend_only]
|
|
261
|
-
interactive = args.empty? && $stdin.tty?
|
|
262
|
-
args = ask("Fields (e.g. name:string price:decimal description:text:optional):").split if interactive
|
|
263
|
-
abort("Give at least one field, e.g. `gemstack generate resource #{name} name:string`") if args.empty?
|
|
264
|
-
|
|
265
|
-
spec = ResourceSpec.new(name, args, actions: resource_actions(interactive))
|
|
266
|
-
parts = resource_parts(root, interactive)
|
|
267
|
-
tests = !options[:skip_tests] && (!interactive || yes?("Generate tests? [Y/n]"))
|
|
268
|
-
ResourceGenerator.new(spec, root: root, parts: parts, tests: tests, force: options[:force]).run
|
|
269
|
-
refresh_contract(root) unless options[:skip_contract]
|
|
270
|
-
page = " · open http://localhost:3000/#{spec.url_segment}" if parts.include?(:frontend)
|
|
271
|
-
say("\nNext: gemstack db:migrate#{page}")
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
def resource_parts(root, interactive)
|
|
275
|
-
parts = options[:frontend_only] ? [] : %i[migration model serializer controller]
|
|
276
|
-
frontend = File.directory?(File.join(root, "frontend")) && !options[:api_only]
|
|
277
|
-
frontend &&= yes?("Generate Next.js pages? [Y/n]") if interactive
|
|
278
|
-
frontend ? parts + [:frontend] : parts
|
|
279
|
-
end
|
|
280
|
-
|
|
281
|
-
def resource_actions(interactive)
|
|
282
|
-
return options[:actions].split(",").map(&:strip) if options[:actions]
|
|
283
|
-
return ResourceSpec::REST_ACTIONS if !interactive || yes?("Generate the full CRUD API? [Y/n]")
|
|
284
|
-
|
|
285
|
-
ask("Actions (comma-separated from #{ResourceSpec::REST_ACTIONS.join(",")}):").split(",").map(&:strip)
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
# Thor's yes? only accepts y/yes; treat an empty answer as "yes".
|
|
289
|
-
def yes?(question)
|
|
290
|
-
answer = ask(question).strip.downcase
|
|
291
|
-
answer.empty? || %w[y yes].include?(answer)
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
# Regenerates TypeScript types/clients in the app's own bundle.
|
|
295
|
-
def refresh_contract(root)
|
|
296
|
-
say(" #{"run".rjust(9)} gemstack contract")
|
|
297
|
-
ok = (defined?(Bundler) ? Bundler.with_unbundled_env { contract_command(root) } : contract_command(root))
|
|
298
|
-
return if ok
|
|
299
|
-
|
|
300
|
-
say(" #{"warning".rjust(9)} contract not generated — run `gemstack contract` once the database is reachable")
|
|
301
|
-
end
|
|
233
|
+
desc "update [VERSION]", "Update this app to the latest GemStack release (or VERSION)"
|
|
234
|
+
long_desc <<~DESC
|
|
235
|
+
Sets every GemStack gem in the Gemfile (gemstack, gemstack-auth, gemstack-realtime) to
|
|
236
|
+
"~> VERSION" and runs bundle update for them. Without VERSION, uses the latest release on
|
|
237
|
+
rubygems.org.
|
|
302
238
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
239
|
+
gemstack update # the latest release
|
|
240
|
+
gemstack update 0.3.5 # a specific version
|
|
241
|
+
DESC
|
|
242
|
+
def update(version = nil)
|
|
243
|
+
exit(1) unless UpdateGenerator.new(root: Project.root!, version: version).run
|
|
306
244
|
end
|
|
245
|
+
|
|
246
|
+
include Helpers
|
|
307
247
|
end
|
|
308
248
|
end
|
data/lib/gemstack/version.rb
CHANGED
|
@@ -1,63 +1,65 @@
|
|
|
1
1
|
# syntax=docker/dockerfile:1
|
|
2
|
-
# Production
|
|
2
|
+
# Production image for <%= app_name %> (gemstack generate deploy — docs/deployment.md).
|
|
3
3
|
#
|
|
4
|
-
#
|
|
4
|
+
# One image runs every process; config/deploy.yml (Kamal) gives each role its command:
|
|
5
5
|
<% if frontend? -%>
|
|
6
|
-
#
|
|
6
|
+
# web node frontend/server.js Next.js
|
|
7
7
|
<% end -%>
|
|
8
|
+
# api bundle exec puma -C config/puma.rb Ruby API (bin/docker-entrypoint migrates first)
|
|
9
|
+
<% if jobs? -%>
|
|
10
|
+
# jobs bundle exec gemstack jobs background jobs
|
|
11
|
+
<% end -%>
|
|
12
|
+
#
|
|
13
|
+
# docker build -t <%= service %> .
|
|
8
14
|
#
|
|
9
15
|
# Configuration comes from the environment at runtime (SECRET_KEY_BASE,
|
|
10
|
-
# DATABASE_URL, …); nothing secret is baked into the
|
|
16
|
+
# DATABASE_URL, …); nothing secret is baked into the image.
|
|
11
17
|
|
|
12
18
|
ARG RUBY_VERSION=<%= ruby_version %>
|
|
19
|
+
<% if frontend? -%>
|
|
13
20
|
ARG NODE_VERSION=<%= node_version %>
|
|
21
|
+
<% end -%>
|
|
14
22
|
|
|
15
|
-
|
|
16
|
-
|
|
23
|
+
FROM ruby:${RUBY_VERSION}-slim AS base
|
|
24
|
+
WORKDIR /app
|
|
25
|
+
ENV GEMSTACK_ENV=production BUNDLE_DEPLOYMENT=1 BUNDLE_WITHOUT="development:test" BUNDLE_PATH=/usr/local/bundle
|
|
26
|
+
|
|
27
|
+
# ── Ruby gems and application code ──────────────────────────────────────────
|
|
28
|
+
FROM base AS gems
|
|
17
29
|
RUN apt-get update -qq && apt-get install --no-install-recommends -y <%= build_packages %> \
|
|
18
30
|
&& rm -rf /var/lib/apt/lists/*
|
|
19
|
-
WORKDIR /app
|
|
20
|
-
ENV BUNDLE_DEPLOYMENT=1 BUNDLE_WITHOUT="development:test" BUNDLE_PATH=/usr/local/bundle
|
|
21
31
|
COPY Gemfile Gemfile.lock ./
|
|
22
32
|
COPY vendor/ ./vendor/
|
|
23
33
|
RUN bundle install --jobs 4 && rm -rf /usr/local/bundle/cache "${BUNDLE_PATH}"/ruby/*/cache
|
|
24
34
|
COPY . .
|
|
25
|
-
RUN rm -rf frontend tmp/* log/* .env*
|
|
26
|
-
|
|
27
|
-
FROM ruby:${RUBY_VERSION}-slim AS api
|
|
28
|
-
RUN apt-get update -qq && apt-get install --no-install-recommends -y <%= runtime_packages %> \
|
|
29
|
-
&& rm -rf /var/lib/apt/lists/* \
|
|
30
|
-
&& groupadd --system --gid 1000 app && useradd --system --uid 1000 --gid app --create-home app<% if sqlite? %> \
|
|
31
|
-
&& mkdir -p /data && chown app:app /data<% end %>
|
|
32
|
-
WORKDIR /app
|
|
33
|
-
ENV GEMSTACK_ENV=production BUNDLE_DEPLOYMENT=1 BUNDLE_WITHOUT="development:test" BUNDLE_PATH=/usr/local/bundle \
|
|
34
|
-
GEMSTACK_API_HOST=0.0.0.0 GEMSTACK_API_PORT=4000
|
|
35
|
-
COPY --from=api-build /usr/local/bundle /usr/local/bundle
|
|
36
|
-
COPY --from=api-build --chown=app:app /app /app
|
|
37
|
-
USER app
|
|
38
|
-
EXPOSE 4000
|
|
39
|
-
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s CMD curl -fsS http://127.0.0.1:4000/api/health || exit 1
|
|
40
|
-
# Other processes use the same image: `bundle exec gemstack jobs`, `bundle exec gemstack db:migrate`.
|
|
41
|
-
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
|
|
35
|
+
RUN rm -rf frontend tmp/* log/* .env* .kamal
|
|
42
36
|
<% if frontend? -%>
|
|
43
37
|
|
|
44
|
-
# ── Next.js
|
|
45
|
-
FROM node:${NODE_VERSION}-slim AS
|
|
46
|
-
WORKDIR /
|
|
38
|
+
# ── Next.js (standalone build: the server and only the modules it needs) ─────
|
|
39
|
+
FROM node:${NODE_VERSION}-slim AS frontend
|
|
40
|
+
WORKDIR /frontend
|
|
47
41
|
COPY frontend/package.json frontend/package-lock.json ./
|
|
48
42
|
RUN npm ci
|
|
49
43
|
COPY frontend/ ./
|
|
50
|
-
ENV NEXT_TELEMETRY_DISABLED=1
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
RUN npm run build && npm prune --omit=dev
|
|
44
|
+
ENV NEXT_TELEMETRY_DISABLED=1 GEMSTACK_NEXT_OUTPUT=standalone
|
|
45
|
+
RUN npm run build \
|
|
46
|
+
&& mkdir -p public && cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/
|
|
47
|
+
<% end -%>
|
|
55
48
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
49
|
+
# ── Runtime ─────────────────────────────────────────────────────────────────
|
|
50
|
+
FROM base
|
|
51
|
+
RUN apt-get update -qq && apt-get install --no-install-recommends -y <%= runtime_packages %> \
|
|
52
|
+
&& rm -rf /var/lib/apt/lists/* \
|
|
53
|
+
&& groupadd --system --gid 1000 app && useradd --system --uid 1000 --gid app --create-home app<% if sqlite? %> \
|
|
54
|
+
&& mkdir -p /data && chown app:app /data<% end %>
|
|
55
|
+
COPY --from=gems /usr/local/bundle /usr/local/bundle
|
|
56
|
+
COPY --from=gems --chown=app:app /app /app
|
|
57
|
+
<% if frontend? -%>
|
|
58
|
+
COPY --from=frontend /usr/local/bin/node /usr/local/bin/node
|
|
59
|
+
COPY --from=frontend --chown=app:app /frontend/.next/standalone /app/frontend
|
|
63
60
|
<% end -%>
|
|
61
|
+
ENV GEMSTACK_API_HOST=0.0.0.0 GEMSTACK_API_PORT=4000<% if frontend? %> NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000<% end %>
|
|
62
|
+
USER app
|
|
63
|
+
EXPOSE <%= frontend? ? "3000 4000" : "4000" %>
|
|
64
|
+
ENTRYPOINT ["/app/bin/docker-entrypoint"]
|
|
65
|
+
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Runs before every process in the production image (Dockerfile ENTRYPOINT).
|
|
3
|
+
set -e
|
|
4
|
+
|
|
5
|
+
case "$*" in
|
|
6
|
+
<% if database? -%>
|
|
7
|
+
*puma*)
|
|
8
|
+
# The API applies pending migrations before it starts serving, so a
|
|
9
|
+
# deploy never runs new code against an old schema.
|
|
10
|
+
bundle exec gemstack db:migrate
|
|
11
|
+
;;
|
|
12
|
+
<% end -%>
|
|
13
|
+
<% if frontend? -%>
|
|
14
|
+
*server.js*)
|
|
15
|
+
# Docker sets HOSTNAME to the container's name; Next.js listens on it.
|
|
16
|
+
export HOSTNAME=0.0.0.0
|
|
17
|
+
;;
|
|
18
|
+
<% end -%>
|
|
19
|
+
esac
|
|
20
|
+
|
|
21
|
+
exec "$@"
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Kamal deploy configuration for <%= app_name %> (gemstack generate deploy — docs/deployment.md).
|
|
2
|
+
#
|
|
3
|
+
# bundle exec kamal setup # first deploy: Docker, kamal-proxy (HTTPS) and accessories on the server
|
|
4
|
+
# bundle exec kamal deploy # every deploy after that, with zero downtime
|
|
5
|
+
#
|
|
6
|
+
# Replace the placeholders marked CHANGE. Secrets are read from .kamal/secrets,
|
|
7
|
+
# which only references environment variables, never their values.
|
|
8
|
+
|
|
9
|
+
service: <%= service %>
|
|
10
|
+
|
|
11
|
+
# CHANGE: your registry user (Docker Hub here; see registry: below for others).
|
|
12
|
+
image: your-user/<%= service %>
|
|
13
|
+
|
|
14
|
+
servers:
|
|
15
|
+
<% if frontend? -%>
|
|
16
|
+
web:
|
|
17
|
+
hosts:
|
|
18
|
+
- 192.168.0.1 # CHANGE: your server's IP or hostname
|
|
19
|
+
cmd: node frontend/server.js
|
|
20
|
+
env:
|
|
21
|
+
clear:
|
|
22
|
+
# Server Components call the API directly, through the alias below.
|
|
23
|
+
GEMSTACK_API_URL: http://<%= service %>-api:4000
|
|
24
|
+
api:
|
|
25
|
+
hosts:
|
|
26
|
+
- 192.168.0.1 # CHANGE
|
|
27
|
+
cmd: bundle exec puma -C config/puma.rb
|
|
28
|
+
options:
|
|
29
|
+
network-alias: <%= service %>-api
|
|
30
|
+
# The browser calls /api on the same origin: kamal-proxy sends it here.
|
|
31
|
+
proxy:
|
|
32
|
+
app_port: 4000
|
|
33
|
+
path_prefix: /api
|
|
34
|
+
strip_path_prefix: false
|
|
35
|
+
# HTTPS comes from the web role's certificate (same domain, root path).
|
|
36
|
+
ssl: false
|
|
37
|
+
# Don't trust X-Forwarded-* headers sent by clients (IP-based rate limits).
|
|
38
|
+
forward_headers: false
|
|
39
|
+
healthcheck:
|
|
40
|
+
path: /api/health
|
|
41
|
+
env:
|
|
42
|
+
clear:
|
|
43
|
+
WEB_CONCURRENCY: 2
|
|
44
|
+
<% else -%>
|
|
45
|
+
api:
|
|
46
|
+
hosts:
|
|
47
|
+
- 192.168.0.1 # CHANGE: your server's IP or hostname
|
|
48
|
+
cmd: bundle exec puma -C config/puma.rb
|
|
49
|
+
env:
|
|
50
|
+
clear:
|
|
51
|
+
WEB_CONCURRENCY: 2
|
|
52
|
+
<% end -%>
|
|
53
|
+
<% if jobs? -%>
|
|
54
|
+
jobs:
|
|
55
|
+
hosts:
|
|
56
|
+
- 192.168.0.1 # CHANGE
|
|
57
|
+
cmd: bundle exec gemstack jobs
|
|
58
|
+
<% end -%>
|
|
59
|
+
|
|
60
|
+
primary_role: <%= frontend? ? "web" : "api" %>
|
|
61
|
+
|
|
62
|
+
# HTTPS with a Let's Encrypt certificate: point the domain's DNS at the server first.
|
|
63
|
+
proxy:
|
|
64
|
+
ssl: true
|
|
65
|
+
host: app.example.com # CHANGE: your domain
|
|
66
|
+
<% if frontend? -%>
|
|
67
|
+
app_port: 3000
|
|
68
|
+
healthcheck:
|
|
69
|
+
path: /
|
|
70
|
+
<% else -%>
|
|
71
|
+
app_port: 4000
|
|
72
|
+
healthcheck:
|
|
73
|
+
path: /api/health
|
|
74
|
+
<% end -%>
|
|
75
|
+
|
|
76
|
+
registry:
|
|
77
|
+
# server: ghcr.io # for GitHub's registry; Docker Hub is the default
|
|
78
|
+
username: your-user # CHANGE
|
|
79
|
+
password:
|
|
80
|
+
- KAMAL_REGISTRY_PASSWORD
|
|
81
|
+
|
|
82
|
+
env:
|
|
83
|
+
clear:
|
|
84
|
+
GEMSTACK_ENV: production
|
|
85
|
+
APP_URL: https://app.example.com # CHANGE: the public URL (email links)
|
|
86
|
+
<% if sqlite? -%>
|
|
87
|
+
DATABASE_URL: sqlite3:/data/production.sqlite3
|
|
88
|
+
<% end -%>
|
|
89
|
+
<% if redis? -%>
|
|
90
|
+
REDIS_URL: redis://<%= service %>-redis:6379/0
|
|
91
|
+
<% end -%>
|
|
92
|
+
<% if mail? -%>
|
|
93
|
+
MAIL_FROM: app@example.com # CHANGE
|
|
94
|
+
<% end -%>
|
|
95
|
+
secret:
|
|
96
|
+
- SECRET_KEY_BASE
|
|
97
|
+
<% if database? && !sqlite? -%>
|
|
98
|
+
- DATABASE_URL
|
|
99
|
+
<% end -%>
|
|
100
|
+
<% if mail? -%>
|
|
101
|
+
- SMTP_URL
|
|
102
|
+
<% end -%>
|
|
103
|
+
<% if sqlite? -%>
|
|
104
|
+
|
|
105
|
+
# The SQLite file lives on this volume, shared by every role: keep them on one server.
|
|
106
|
+
volumes:
|
|
107
|
+
- "<%= service %>_data:/data"
|
|
108
|
+
<% end -%>
|
|
109
|
+
|
|
110
|
+
builder:
|
|
111
|
+
arch: amd64
|
|
112
|
+
<% if postgresql? || mysql? || redis? -%>
|
|
113
|
+
|
|
114
|
+
accessories:
|
|
115
|
+
<% if postgresql? -%>
|
|
116
|
+
db:
|
|
117
|
+
image: postgres:17
|
|
118
|
+
host: 192.168.0.1 # CHANGE
|
|
119
|
+
env:
|
|
120
|
+
clear:
|
|
121
|
+
POSTGRES_USER: app
|
|
122
|
+
POSTGRES_DB: <%= database_name %>
|
|
123
|
+
secret:
|
|
124
|
+
- POSTGRES_PASSWORD
|
|
125
|
+
directories:
|
|
126
|
+
- data:/var/lib/postgresql/data
|
|
127
|
+
<% elsif mysql? -%>
|
|
128
|
+
db:
|
|
129
|
+
image: mysql:8.4
|
|
130
|
+
host: 192.168.0.1 # CHANGE
|
|
131
|
+
env:
|
|
132
|
+
clear:
|
|
133
|
+
MYSQL_USER: app
|
|
134
|
+
MYSQL_DATABASE: <%= database_name %>
|
|
135
|
+
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
|
|
136
|
+
secret:
|
|
137
|
+
- MYSQL_PASSWORD
|
|
138
|
+
directories:
|
|
139
|
+
- data:/var/lib/mysql
|
|
140
|
+
<% end -%>
|
|
141
|
+
<% if redis? -%>
|
|
142
|
+
redis:
|
|
143
|
+
image: redis:7
|
|
144
|
+
host: 192.168.0.1 # CHANGE
|
|
145
|
+
cmd: redis-server --save "" --appendonly no
|
|
146
|
+
<% end -%>
|
|
147
|
+
<% end -%>
|
|
148
|
+
|
|
149
|
+
# bundle exec kamal console | logs | migrate | shell
|
|
150
|
+
aliases:
|
|
151
|
+
console: app exec --interactive --reuse --roles=api "bundle exec gemstack console"
|
|
152
|
+
logs: app logs --follow
|
|
153
|
+
<% if database? -%>
|
|
154
|
+
migrate: app exec --roles=api "bundle exec gemstack db:migrate"
|
|
155
|
+
<% end -%>
|
|
156
|
+
shell: app exec --interactive --reuse --roles=api "bash"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Secrets for Kamal (config/deploy.yml), resolved on your machine at deploy time.
|
|
2
|
+
#
|
|
3
|
+
# This file is committed to git: only ever write REFERENCES here — environment
|
|
4
|
+
# variables ($NAME) or password-manager commands ($(kamal secrets fetch ...)) —
|
|
5
|
+
# never a secret's value. `gemstack doctor` flags values written in place.
|
|
6
|
+
|
|
7
|
+
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
|
|
8
|
+
SECRET_KEY_BASE=$SECRET_KEY_BASE
|
|
9
|
+
<% if postgresql? -%>
|
|
10
|
+
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
|
|
11
|
+
DATABASE_URL=postgres://app:$POSTGRES_PASSWORD@<%= service %>-db:5432/<%= database_name %>
|
|
12
|
+
<% elsif mysql? -%>
|
|
13
|
+
MYSQL_PASSWORD=$MYSQL_PASSWORD
|
|
14
|
+
DATABASE_URL=<%= database_adapter %>://app:$MYSQL_PASSWORD@<%= service %>-db:3306/<%= database_name %>
|
|
15
|
+
<% end -%>
|
|
16
|
+
<% if mail? -%>
|
|
17
|
+
SMTP_URL=$SMTP_URL
|
|
18
|
+
<% end -%>
|
|
@@ -2,12 +2,15 @@ import type { NextConfig } from "next";
|
|
|
2
2
|
|
|
3
3
|
// One public origin, in every environment:
|
|
4
4
|
// - `gemstack dev`: the GemStack gateway routes /api/* to Ruby before requests reach Next.js.
|
|
5
|
-
// - Production
|
|
6
|
-
//
|
|
5
|
+
// - Production with Kamal (gemstack generate deploy): kamal-proxy routes /api/* to Ruby.
|
|
6
|
+
// - Elsewhere: set GEMSTACK_API_URL (e.g. http://api.internal:4000) at build time and
|
|
7
|
+
// Next.js proxies /api/* to the Ruby API.
|
|
7
8
|
const apiUrl = process.env.GEMSTACK_API_URL?.replace(/\/$/, "");
|
|
8
9
|
const apiPath = process.env.NEXT_PUBLIC_GEMSTACK_API_PATH ?? "/api";
|
|
9
10
|
|
|
10
11
|
const nextConfig: NextConfig = {
|
|
12
|
+
// The production image (gemstack generate deploy) runs Next.js's standalone server.
|
|
13
|
+
output: process.env.GEMSTACK_NEXT_OUTPUT === "standalone" ? "standalone" : undefined,
|
|
11
14
|
async rewrites() {
|
|
12
15
|
if (!apiUrl) return [];
|
|
13
16
|
return [{ source: `${apiPath}/:path*`, destination: `${apiUrl}${apiPath}/:path*` }];
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gemstack
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Adware Technologies
|
|
@@ -44,14 +44,14 @@ dependencies:
|
|
|
44
44
|
requirements:
|
|
45
45
|
- - '='
|
|
46
46
|
- !ruby/object:Gem::Version
|
|
47
|
-
version: 0.3.
|
|
47
|
+
version: 0.3.5
|
|
48
48
|
type: :runtime
|
|
49
49
|
prerelease: false
|
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
|
51
51
|
requirements:
|
|
52
52
|
- - '='
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
|
-
version: 0.3.
|
|
54
|
+
version: 0.3.5
|
|
55
55
|
- !ruby/object:Gem::Dependency
|
|
56
56
|
name: json
|
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -181,9 +181,11 @@ files:
|
|
|
181
181
|
- lib/gemstack/cli/deploy_generator.rb
|
|
182
182
|
- lib/gemstack/cli/destroy_generator.rb
|
|
183
183
|
- lib/gemstack/cli/doctor.rb
|
|
184
|
+
- lib/gemstack/cli/doctor/secret_checks.rb
|
|
184
185
|
- lib/gemstack/cli/doctor/upgrade_check.rb
|
|
185
186
|
- lib/gemstack/cli/generation_manifest.rb
|
|
186
187
|
- lib/gemstack/cli/generator.rb
|
|
188
|
+
- lib/gemstack/cli/helpers.rb
|
|
187
189
|
- lib/gemstack/cli/job_generator.rb
|
|
188
190
|
- lib/gemstack/cli/migration_changes.rb
|
|
189
191
|
- lib/gemstack/cli/migration_generator.rb
|
|
@@ -193,6 +195,7 @@ files:
|
|
|
193
195
|
- lib/gemstack/cli/project.rb
|
|
194
196
|
- lib/gemstack/cli/resource_generator.rb
|
|
195
197
|
- lib/gemstack/cli/resource_spec.rb
|
|
198
|
+
- lib/gemstack/cli/update_generator.rb
|
|
196
199
|
- lib/gemstack/contract.rb
|
|
197
200
|
- lib/gemstack/contract/builder.rb
|
|
198
201
|
- lib/gemstack/contract/docs.rb
|
|
@@ -329,11 +332,11 @@ files:
|
|
|
329
332
|
- templates/auth/test/controllers/auth_test.rb
|
|
330
333
|
- templates/controller/app/controllers/%file_name%_controller.rb.tt
|
|
331
334
|
- templates/controller/test/controllers/%file_name%_controller_test.rb.tt
|
|
332
|
-
- templates/deploy/Caddyfile.tt
|
|
333
335
|
- templates/deploy/Dockerfile.tt
|
|
334
|
-
- templates/deploy/
|
|
335
|
-
- templates/deploy/
|
|
336
|
+
- templates/deploy/bin/docker-entrypoint.tt
|
|
337
|
+
- templates/deploy/config/deploy.yml.tt
|
|
336
338
|
- templates/deploy/dot_dockerignore
|
|
339
|
+
- templates/deploy/dot_kamal/secrets.tt
|
|
337
340
|
- templates/frontend/app/globals.css
|
|
338
341
|
- templates/frontend/app/layout.tsx.tt
|
|
339
342
|
- templates/frontend/app/page.module.css
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
# One public origin: /api/* → Ruby, everything else → Next.js (docs/deployment.md).
|
|
2
|
-
# SITE_ADDRESS=example.com gets a Let's Encrypt certificate automatically;
|
|
3
|
-
# "localhost" uses Caddy's local CA (trust it with `caddy trust`, or curl -k).
|
|
4
|
-
{$SITE_ADDRESS:localhost} {
|
|
5
|
-
encode zstd gzip
|
|
6
|
-
|
|
7
|
-
handle /api/* {
|
|
8
|
-
reverse_proxy api:4000 {
|
|
9
|
-
# Server-Sent Events: stream responses immediately.
|
|
10
|
-
flush_interval -1
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
<% if frontend? -%>
|
|
14
|
-
|
|
15
|
-
handle {
|
|
16
|
-
reverse_proxy web:3000
|
|
17
|
-
}
|
|
18
|
-
<% end -%>
|
|
19
|
-
}
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
# Process types for Procfile platforms (Heroku, Render, Railway, Dokku…) — docs/deployment.md.
|
|
2
|
-
release: bundle exec gemstack db:migrate
|
|
3
|
-
web: GEMSTACK_API_PORT=${PORT:-4000} bundle exec puma -C config/puma.rb
|
|
4
|
-
<% if jobs? -%>
|
|
5
|
-
worker: bundle exec gemstack jobs
|
|
6
|
-
<% end -%>
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
# Runs <%= app_name %> in production mode on this machine, behind Caddy on https://localhost:
|
|
2
|
-
#
|
|
3
|
-
# export SECRET_KEY_BASE=$(openssl rand -hex 64)<% if postgresql? %> POSTGRES_PASSWORD=$(openssl rand -hex 16)<% elsif mysql? %> MYSQL_PASSWORD=$(openssl rand -hex 16)<% end %>
|
|
4
|
-
# docker compose up --build
|
|
5
|
-
#
|
|
6
|
-
# It's also a map of what a real deploy needs: one image per process type,
|
|
7
|
-
# migrations before the API starts, and a proxy sending /api to Ruby.
|
|
8
|
-
<% if sqlite? -%>
|
|
9
|
-
# SQLite: the database file lives on the `data` volume, shared by the API, the
|
|
10
|
-
# jobs worker and migrations — keep them on one host.
|
|
11
|
-
<% end -%>
|
|
12
|
-
name: <%= app_name.tr("_", "-") %>
|
|
13
|
-
|
|
14
|
-
x-api-env: &api-env
|
|
15
|
-
GEMSTACK_ENV: production
|
|
16
|
-
SECRET_KEY_BASE: ${SECRET_KEY_BASE:?set SECRET_KEY_BASE (openssl rand -hex 64)}
|
|
17
|
-
<% if database? -%>
|
|
18
|
-
DATABASE_URL: <%= database_url %>
|
|
19
|
-
<% end -%>
|
|
20
|
-
<% if redis? -%>
|
|
21
|
-
REDIS_URL: redis://redis:6379/0 # realtime fan-out between the API and the jobs worker
|
|
22
|
-
<% end -%>
|
|
23
|
-
APP_URL: ${APP_URL:-https://localhost}
|
|
24
|
-
<% if mail? -%>
|
|
25
|
-
SMTP_URL: ${SMTP_URL:-} # e.g. smtp://user:pass@smtp.example.com:587 — emails retry until it is set
|
|
26
|
-
MAIL_FROM: ${MAIL_FROM:-}
|
|
27
|
-
<% end -%>
|
|
28
|
-
|
|
29
|
-
services:
|
|
30
|
-
<% if postgresql? -%>
|
|
31
|
-
db:
|
|
32
|
-
image: postgres:17
|
|
33
|
-
environment:
|
|
34
|
-
POSTGRES_USER: app
|
|
35
|
-
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
|
36
|
-
POSTGRES_DB: app
|
|
37
|
-
volumes: [db:/var/lib/postgresql/data]
|
|
38
|
-
healthcheck:
|
|
39
|
-
test: ["CMD-SHELL", "pg_isready -U app -d app"]
|
|
40
|
-
interval: 2s
|
|
41
|
-
retries: 30
|
|
42
|
-
|
|
43
|
-
<% elsif mysql? -%>
|
|
44
|
-
db:
|
|
45
|
-
image: mysql:8.4
|
|
46
|
-
environment:
|
|
47
|
-
MYSQL_DATABASE: app
|
|
48
|
-
MYSQL_USER: app
|
|
49
|
-
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?set MYSQL_PASSWORD}
|
|
50
|
-
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
|
|
51
|
-
volumes: [db:/var/lib/mysql]
|
|
52
|
-
healthcheck:
|
|
53
|
-
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uapp -p$$MYSQL_PASSWORD --silent"]
|
|
54
|
-
interval: 3s
|
|
55
|
-
retries: 40
|
|
56
|
-
|
|
57
|
-
<% end -%>
|
|
58
|
-
<% if redis? -%>
|
|
59
|
-
redis:
|
|
60
|
-
image: redis:7
|
|
61
|
-
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
|
62
|
-
|
|
63
|
-
<% end -%>
|
|
64
|
-
<% if database? -%>
|
|
65
|
-
migrate:
|
|
66
|
-
image: <%= app_name %>-api
|
|
67
|
-
build: { context: ., target: api }
|
|
68
|
-
healthcheck: { disable: true }
|
|
69
|
-
command: ["bundle", "exec", "gemstack", "db:migrate"]
|
|
70
|
-
environment: *api-env
|
|
71
|
-
<% if sqlite? -%>
|
|
72
|
-
volumes: [data:/data]
|
|
73
|
-
<% else -%>
|
|
74
|
-
depends_on: { db: { condition: service_healthy } }
|
|
75
|
-
<% end -%>
|
|
76
|
-
|
|
77
|
-
<% end -%>
|
|
78
|
-
api:
|
|
79
|
-
image: <%= app_name %>-api
|
|
80
|
-
build: { context: ., target: api }
|
|
81
|
-
environment:
|
|
82
|
-
<<: *api-env
|
|
83
|
-
WEB_CONCURRENCY: ${WEB_CONCURRENCY:-2}
|
|
84
|
-
<% if sqlite? -%>
|
|
85
|
-
volumes: [data:/data]
|
|
86
|
-
<% end -%>
|
|
87
|
-
<% if database? -%>
|
|
88
|
-
depends_on: { migrate: { condition: service_completed_successfully } }
|
|
89
|
-
<% end -%>
|
|
90
|
-
<% if jobs? -%>
|
|
91
|
-
|
|
92
|
-
jobs:
|
|
93
|
-
image: <%= app_name %>-api
|
|
94
|
-
build: { context: ., target: api }
|
|
95
|
-
healthcheck: { disable: true }
|
|
96
|
-
command: ["bundle", "exec", "gemstack", "jobs"]
|
|
97
|
-
environment: *api-env
|
|
98
|
-
<% if sqlite? -%>
|
|
99
|
-
volumes: [data:/data]
|
|
100
|
-
<% end -%>
|
|
101
|
-
depends_on: { migrate: { condition: service_completed_successfully } }
|
|
102
|
-
<% end -%>
|
|
103
|
-
<% if frontend? -%>
|
|
104
|
-
|
|
105
|
-
web:
|
|
106
|
-
image: <%= app_name %>-web
|
|
107
|
-
build: { context: ., target: web }
|
|
108
|
-
environment:
|
|
109
|
-
GEMSTACK_API_URL: http://api:4000 # Server Components call the API directly
|
|
110
|
-
depends_on: [api]
|
|
111
|
-
<% end -%>
|
|
112
|
-
|
|
113
|
-
proxy:
|
|
114
|
-
image: caddy:2
|
|
115
|
-
ports: ["80:80", "443:443"]
|
|
116
|
-
environment:
|
|
117
|
-
SITE_ADDRESS: ${SITE_ADDRESS:-localhost}
|
|
118
|
-
volumes:
|
|
119
|
-
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
120
|
-
- caddy:/data
|
|
121
|
-
depends_on: [api<%= ", web" if frontend? %>]
|
|
122
|
-
|
|
123
|
-
volumes:
|
|
124
|
-
<% if sqlite? -%>
|
|
125
|
-
data:
|
|
126
|
-
<% elsif database? -%>
|
|
127
|
-
db:
|
|
128
|
-
<% end -%>
|
|
129
|
-
caddy:
|