Skip to content

Commit c387c07

Browse files
authored
Merge pull request #972 from flippercloud/cloud-migrate-api-cli-ui
Auto Cloud Migration
2 parents a2aca23 + 7470aa3 commit c387c07

11 files changed

Lines changed: 458 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ The project is structured as multiple gems:
6969
**Memoization**: Automatic caching of feature checks within request/thread scope
7070
**Type Safety**: Strong typing system for actors, percentages, and other values
7171

72+
### Serialization and HTTP
73+
74+
Use `Flipper::Typecast` for JSON and gzip serialization instead of calling `JSON.generate`/`JSON.parse` or `Zlib` directly:
75+
- `Typecast.to_json(hash)` / `Typecast.from_json(string)` for JSON serialization
76+
- `Typecast.to_gzip(string)` / `Typecast.from_gzip(string)` for gzip compression
77+
78+
For outbound HTTP requests, use `Flipper::Adapters::Http::Client` instead of raw `Net::HTTP`. It provides timeouts, retries (`max_retries`), SSL verification, and diagnostic headers (user-agent, client-language, client-platform, etc.). See `lib/flipper/cloud/migrate.rb` for an example.
79+
7280
### Testing
7381

7482
Uses both RSpec (currently preferred for new tests) and Minitest. Shared adapter specs ensure consistency across all storage backends. Extensive testing across multiple Rails versions (5.0-8.0).

lib/flipper/cli.rb

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,57 @@ def initialize(stdout: $stdout, stderr: $stderr, shell: Bundler::Thor::Base.shel
8484
end
8585
end
8686

87+
command 'export' do |c|
88+
c.description = "Export features as JSON"
89+
c.action do
90+
export = Flipper.export(format: :json, version: 1)
91+
ui.info export.contents
92+
end
93+
end
94+
95+
command 'cloud' do |c|
96+
c.description = "Flipper Cloud commands"
97+
c.action do |subcommand = nil, *args|
98+
require 'flipper/cloud/migrate'
99+
100+
case subcommand
101+
when 'migrate'
102+
result = Flipper::Cloud.migrate(Flipper)
103+
if result.url
104+
ui.info "Migrating to Flipper Cloud..."
105+
ui.info result.url
106+
system("open", result.url)
107+
else
108+
message = "Migration failed (HTTP #{result.code})"
109+
message << ": #{result.message}" if result.message
110+
ui.error message
111+
exit 1
112+
end
113+
when 'push'
114+
token = args.first
115+
unless token
116+
ui.error "Usage: flipper cloud push <token>"
117+
exit 1
118+
end
119+
result = Flipper::Cloud.push(token, Flipper)
120+
if result.code == 204
121+
ui.info "Successfully pushed features to Flipper Cloud"
122+
else
123+
message = "Push failed (HTTP #{result.code})"
124+
message << ": #{result.message}" if result.message
125+
ui.error message
126+
exit 1
127+
end
128+
else
129+
ui.info "Usage: flipper cloud <command>"
130+
ui.info ""
131+
ui.info "Commands:"
132+
ui.info " migrate Migrate features to a new Flipper Cloud account"
133+
ui.info " push Push features to an existing Flipper Cloud project"
134+
end
135+
end
136+
end
137+
87138
command 'help' do |c|
88139
c.load_environment = false
89140
c.action do |command = nil|

lib/flipper/cloud.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require "flipper/cloud/configuration"
55
require "flipper/cloud/dsl"
66
require "flipper/cloud/middleware"
7+
require "flipper/cloud/migrate"
78

89
module Flipper
910
module Cloud

lib/flipper/cloud/migrate.rb

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
require "flipper/adapters/http/client"
2+
require "flipper/typecast"
3+
4+
module Flipper
5+
module Cloud
6+
MigrateResult = Struct.new(:code, :url, :message, keyword_init: true)
7+
8+
DEFAULT_CLOUD_URL = "https://www.flippercloud.io".freeze
9+
10+
# Public: Migrate features to Flipper Cloud.
11+
#
12+
# flipper - The Flipper instance to export features from (default: Flipper).
13+
# app_name - Optional String name of the application.
14+
#
15+
# Returns a MigrateResult with code, url, and message.
16+
def self.migrate(flipper = Flipper, app_name: nil)
17+
export = flipper.export(format: :json, version: 1)
18+
payload = {
19+
export: Typecast.from_json(export.contents),
20+
metadata: {app_name: app_name},
21+
}
22+
23+
client = build_client("/api")
24+
response = client.post("/migrate", Typecast.to_gzip(Typecast.to_json(payload)))
25+
body = Typecast.from_json(response.body) rescue nil
26+
27+
MigrateResult.new(
28+
code: response.code.to_i,
29+
url: body&.dig("url"),
30+
message: body&.dig("error"),
31+
)
32+
end
33+
34+
# Public: Push features to an existing Flipper Cloud project.
35+
#
36+
# token - The String token for the Cloud environment.
37+
# flipper - The Flipper instance to export features from (default: Flipper).
38+
#
39+
# Returns a MigrateResult with code and message.
40+
def self.push(token, flipper = Flipper)
41+
export = flipper.export(format: :json, version: 1)
42+
43+
client = build_client("/adapter", headers: {
44+
"flipper-cloud-token" => token,
45+
})
46+
response = client.post("/import", Typecast.to_gzip(export.contents))
47+
body = Typecast.from_json(response.body) rescue nil
48+
49+
MigrateResult.new(
50+
code: response.code.to_i,
51+
url: nil,
52+
message: body&.dig("error"),
53+
)
54+
end
55+
56+
# Private: Build an HTTP client for Cloud API requests.
57+
def self.build_client(path, headers: {})
58+
base_url = ENV.fetch("FLIPPER_CLOUD_URL", DEFAULT_CLOUD_URL)
59+
60+
Flipper::Adapters::Http::Client.new(
61+
url: "#{base_url}#{path}",
62+
headers: {"content-encoding" => "gzip"}.merge(headers),
63+
open_timeout: 5,
64+
read_timeout: 30,
65+
write_timeout: 30,
66+
max_retries: 2,
67+
)
68+
end
69+
private_class_method :build_client
70+
end
71+
end
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
require 'flipper/ui/action'
2+
require 'flipper/ui/util'
3+
4+
module Flipper
5+
module UI
6+
module Actions
7+
class CloudMigrate < UI::Action
8+
route %r{\A/settings\/cloud/?\Z}
9+
10+
def post
11+
result = Flipper::Cloud.migrate(flipper)
12+
13+
if result.url
14+
status 302
15+
header 'location', result.url
16+
halt [@code, @headers, ['']]
17+
else
18+
message = "Migration failed (HTTP #{result.code})"
19+
message << ": #{result.message}" if result.message
20+
redirect_to "/settings?error=#{Flipper::UI::Util.escape(message)}"
21+
end
22+
end
23+
end
24+
end
25+
end
26+
end

lib/flipper/ui/middleware.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def initialize(app, options = {})
2727
@action_collection.add UI::Actions::Features
2828
@action_collection.add UI::Actions::Export
2929
@action_collection.add UI::Actions::Import
30+
@action_collection.add UI::Actions::CloudMigrate
3031
@action_collection.add UI::Actions::Settings
3132

3233
# Static Assets/Files

lib/flipper/ui/views/settings.erb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,30 @@
1+
<% if params.key?("error") %>
2+
<div class="alert alert-danger"><%= params["error"] %></div>
3+
<% end %>
4+
5+
<div class="card mb-4">
6+
<div class="card-header">
7+
<h4 class="m-0">Migrate to Flipper Cloud</h4>
8+
</div>
9+
<div class="card-body">
10+
<p>Flipper Cloud gives you audit history, rollback, finer-grained permissions, and multi-environment sync. We even have a free tier!</p>
11+
12+
<ul class="mb-3">
13+
<li>Audit log of every feature change</li>
14+
<li>Instant rollback to any previous state</li>
15+
<li>Team permissions and approval workflows</li>
16+
<li>Sync flags across environments</li>
17+
</ul>
18+
19+
<p>You have <strong><%= flipper.features.count %></strong> <%= flipper.features.count == 1 ? 'feature' : 'features' %> ready to migrate.</p>
20+
21+
<form action="<%= script_name %>/settings/cloud" method="post">
22+
<%== csrf_input_tag %>
23+
<input type="submit" value="Migrate to Flipper Cloud" class="btn btn-primary">
24+
</form>
25+
</div>
26+
</div>
27+
128
<div class="card mb-4 ">
229
<div class="card-header">
330
<h4 class="m-0">Export</h4>

spec/flipper/cli_spec.rb

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,57 @@
147147
it { should have_attributes(status: 1, stderr: /invalid option: --nope/) }
148148
end
149149

150+
describe "export" do
151+
before do
152+
Flipper.enable :search
153+
Flipper.disable :analytics
154+
end
155+
156+
it "outputs valid JSON export" do
157+
expect(subject).to have_attributes(status: 0)
158+
data = JSON.parse(subject.stdout)
159+
expect(data["version"]).to eq(1)
160+
expect(data["features"]).to have_key("search")
161+
expect(data["features"]).to have_key("analytics")
162+
end
163+
end
164+
165+
describe "cloud" do
166+
it "shows help when no subcommand given" do
167+
expect(subject).to have_attributes(status: 0, stdout: /migrate/)
168+
expect(subject.stdout).to match(/push/)
169+
end
170+
end
171+
172+
describe "cloud migrate" do
173+
before do
174+
Flipper.enable :search
175+
require 'flipper/cloud/migrate'
176+
allow(Flipper::Cloud).to receive(:migrate).and_return(
177+
Flipper::Cloud::MigrateResult.new(code: 200, url: "https://www.flippercloud.io/cloud/setup/abc123")
178+
)
179+
allow(cli).to receive(:system)
180+
end
181+
182+
it "prints the cloud URL" do
183+
expect(subject).to have_attributes(status: 0, stdout: /flippercloud\.io/)
184+
end
185+
end
186+
187+
describe "cloud push test-token" do
188+
before do
189+
Flipper.enable :search
190+
require 'flipper/cloud/migrate'
191+
allow(Flipper::Cloud).to receive(:push).and_return(
192+
Flipper::Cloud::MigrateResult.new(code: 204, url: nil)
193+
)
194+
end
195+
196+
it "prints success message" do
197+
expect(subject).to have_attributes(status: 0, stdout: /Successfully pushed/)
198+
end
199+
end
200+
150201
describe "show foo" do
151202
context "boolean" do
152203
before { Flipper.enable :foo }

0 commit comments

Comments
 (0)