fastlane-plugin-infisical 0.1.0
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 +7 -0
- data/LICENSE +202 -0
- data/README.md +165 -0
- data/lib/fastlane/plugin/infisical/actions/infisical_storage_action.rb +32 -0
- data/lib/fastlane/plugin/infisical/storage.rb +428 -0
- data/lib/fastlane/plugin/infisical/storage_manual.rb +385 -0
- data/lib/fastlane/plugin/infisical/storage_sdk.rb +259 -0
- data/lib/fastlane/plugin/infisical/version.rb +5 -0
- data/lib/fastlane/plugin/infisical_storage.rb +86 -0
- metadata +79 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
require "fastlane/plugin/infisical/version"
|
|
2
|
+
require "fastlane_core/ui/ui"
|
|
3
|
+
require "match"
|
|
4
|
+
require "infisical-sdk"
|
|
5
|
+
require "base64"
|
|
6
|
+
require "tempfile"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
|
|
9
|
+
UI = FastlaneCore::UI unless defined?(UI)
|
|
10
|
+
|
|
11
|
+
module Fastlane
|
|
12
|
+
module InfisicalStorage
|
|
13
|
+
class Storage < Match::Storage::Interface
|
|
14
|
+
attr_reader :project_id
|
|
15
|
+
attr_reader :environment
|
|
16
|
+
attr_reader :secret_path
|
|
17
|
+
attr_reader :infisical_url
|
|
18
|
+
attr_accessor :working_directory
|
|
19
|
+
|
|
20
|
+
def self.configure(params)
|
|
21
|
+
return(
|
|
22
|
+
Storage.new(
|
|
23
|
+
project_id: params[:infisical_project_id] || params[:project_id],
|
|
24
|
+
environment: params[:infisical_environment] || params[:environment],
|
|
25
|
+
secret_path: params[:infisical_secret_path] || params[:secret_path],
|
|
26
|
+
infisical_url: params[:infisical_url],
|
|
27
|
+
username: params[:username],
|
|
28
|
+
readonly: params[:readonly],
|
|
29
|
+
team_id: params[:team_id],
|
|
30
|
+
team_name: params[:team_name],
|
|
31
|
+
api_key_path: params[:api_key_path],
|
|
32
|
+
api_key: params[:api_key]
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(
|
|
38
|
+
project_id:,
|
|
39
|
+
environment: "dev",
|
|
40
|
+
secret_path: "/",
|
|
41
|
+
infisical_url: nil,
|
|
42
|
+
username: nil,
|
|
43
|
+
readonly: nil,
|
|
44
|
+
team_id: nil,
|
|
45
|
+
team_name: nil,
|
|
46
|
+
api_key_path: nil,
|
|
47
|
+
api_key: nil
|
|
48
|
+
)
|
|
49
|
+
@project_id = project_id || ENV["INFISICAL_PROJECT_ID"]
|
|
50
|
+
@environment = environment || ENV["INFISICAL_ENVIRONMENT"] || "dev"
|
|
51
|
+
@secret_path = secret_path || ENV["INFISICAL_SECRET_PATH"] || "/"
|
|
52
|
+
@infisical_url =
|
|
53
|
+
infisical_url || ENV["INFISICAL_URL"] || "https://app.infisical.com"
|
|
54
|
+
@username = username
|
|
55
|
+
@readonly = readonly
|
|
56
|
+
@team_id = team_id
|
|
57
|
+
@team_name = team_name
|
|
58
|
+
@api_key_path = api_key_path
|
|
59
|
+
@api_key = api_key
|
|
60
|
+
|
|
61
|
+
# Initialize Infisical SDK client
|
|
62
|
+
@client = InfisicalSDK::InfisicalClient.new(@infisical_url)
|
|
63
|
+
|
|
64
|
+
# Authenticate using the best available method
|
|
65
|
+
authenticate_client
|
|
66
|
+
|
|
67
|
+
UI.message(
|
|
68
|
+
"Initializing match for Infisical at project #{@project_id} in environment #{@environment}"
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def prefixed_working_directory
|
|
73
|
+
# the directory depends on the team_id
|
|
74
|
+
if @team_id
|
|
75
|
+
@_folder_prefix = "team_id/#{@team_id}"
|
|
76
|
+
elsif @team_name
|
|
77
|
+
@_folder_prefix = "team_name/#{@team_name}"
|
|
78
|
+
else
|
|
79
|
+
@_folder_prefix = nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# If team_id/team_name is not set, use the one from the Appfile/Matchfile
|
|
83
|
+
if @_folder_prefix.nil?
|
|
84
|
+
UI.important(
|
|
85
|
+
"Looks like you run `match` in `readonly` mode, and didn't provide a `team_id`. This will still work, however it is recommended to provide a `team_id` in your Appfile or Matchfile"
|
|
86
|
+
)
|
|
87
|
+
@_folder_prefix = "*"
|
|
88
|
+
end
|
|
89
|
+
return File.join(working_directory, @_folder_prefix)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def working_directory
|
|
93
|
+
@working_directory ||= Dir.mktmpdir
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def working_directory=(value)
|
|
97
|
+
@working_directory = value
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def download
|
|
101
|
+
return if @working_directory
|
|
102
|
+
|
|
103
|
+
self.working_directory = Dir.mktmpdir
|
|
104
|
+
|
|
105
|
+
with_infisical_error_handling do
|
|
106
|
+
# List all fastlane secrets from the main fastlane path
|
|
107
|
+
secrets = list_fastlane_secrets
|
|
108
|
+
|
|
109
|
+
secrets.each do |secret|
|
|
110
|
+
secret_name = secret["secretKey"]
|
|
111
|
+
|
|
112
|
+
# Convert the flat secret name back to a file path
|
|
113
|
+
# We need to be smarter about this - only convert the first few dashes that represent directories
|
|
114
|
+
# Common patterns: "profiles-appstore-filename", "certs-development-filename", "filename"
|
|
115
|
+
|
|
116
|
+
if secret_name.include?("-")
|
|
117
|
+
# Try to identify known fastlane directory patterns
|
|
118
|
+
if secret_name.start_with?("profiles-")
|
|
119
|
+
# profiles-TYPE-filename -> profiles/TYPE/filename
|
|
120
|
+
parts = secret_name.split("-", 3) # Split only on first 2 dashes
|
|
121
|
+
if parts.length >= 3
|
|
122
|
+
relative_path = "#{parts[0]}/#{parts[1]}/#{parts[2]}"
|
|
123
|
+
else
|
|
124
|
+
relative_path = secret_name
|
|
125
|
+
end
|
|
126
|
+
elsif secret_name.start_with?("certs-")
|
|
127
|
+
# certs-TYPE-filename -> certs/TYPE/filename
|
|
128
|
+
parts = secret_name.split("-", 3) # Split only on first 2 dashes
|
|
129
|
+
if parts.length >= 3
|
|
130
|
+
relative_path = "#{parts[0]}/#{parts[1]}/#{parts[2]}"
|
|
131
|
+
else
|
|
132
|
+
relative_path = secret_name
|
|
133
|
+
end
|
|
134
|
+
else
|
|
135
|
+
# Unknown pattern, treat as filename
|
|
136
|
+
relative_path = secret_name
|
|
137
|
+
end
|
|
138
|
+
else
|
|
139
|
+
# No dashes, treat as filename
|
|
140
|
+
relative_path = secret_name
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
full_path = File.join(working_directory, relative_path)
|
|
144
|
+
|
|
145
|
+
# Create directory if needed
|
|
146
|
+
FileUtils.mkdir_p(File.dirname(full_path))
|
|
147
|
+
|
|
148
|
+
# Decode and write the file
|
|
149
|
+
content = Base64.strict_decode64(secret["secretValue"])
|
|
150
|
+
File.binwrite(full_path, content)
|
|
151
|
+
|
|
152
|
+
UI.verbose("Downloaded: #{secret_name} -> #{relative_path}")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
UI.success("Downloaded #{secrets.length} files from Infisical")
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def upload_files(files_to_upload, custom_working_directory = nil)
|
|
160
|
+
base_directory = custom_working_directory || working_directory
|
|
161
|
+
|
|
162
|
+
with_infisical_error_handling do
|
|
163
|
+
files_to_upload.each do |file_path|
|
|
164
|
+
# Calculate relative path
|
|
165
|
+
relative_path = file_path.gsub(base_directory + "/", "")
|
|
166
|
+
|
|
167
|
+
# Convert the file path to a flat secret name that includes path info
|
|
168
|
+
# For example: "profiles/appstore/file.mobileprovision" becomes "profiles-appstore-file.mobileprovision"
|
|
169
|
+
# Sanitize the secret name by replacing problematic characters
|
|
170
|
+
secret_name = relative_path.gsub("/", "-").gsub(/[^\w\-\.]/, "_")
|
|
171
|
+
|
|
172
|
+
# Read and encode file content
|
|
173
|
+
content = File.binread(file_path)
|
|
174
|
+
encoded_content = Base64.strict_encode64(content)
|
|
175
|
+
|
|
176
|
+
# Create or update the secret using the fastlane path
|
|
177
|
+
create_or_update_secret(secret_name, encoded_content, "/fastlane")
|
|
178
|
+
|
|
179
|
+
UI.verbose(
|
|
180
|
+
"Uploaded: #{relative_path} -> #{secret_name} (path: /fastlane)"
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
UI.success("Uploaded #{files_to_upload.length} files to Infisical")
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def delete_files(files_to_delete)
|
|
189
|
+
with_infisical_error_handling do
|
|
190
|
+
files_to_delete.each do |file_path|
|
|
191
|
+
# Calculate relative path - handle both absolute and relative paths
|
|
192
|
+
if file_path.start_with?("/")
|
|
193
|
+
# Absolute path - extract just the filename and directory structure
|
|
194
|
+
# Find the last directory that looks like a working directory
|
|
195
|
+
path_parts = file_path.split("/")
|
|
196
|
+
# Look for fastlane-like structure starting from the end
|
|
197
|
+
relevant_parts = []
|
|
198
|
+
path_parts.reverse.each do |part|
|
|
199
|
+
relevant_parts.unshift(part)
|
|
200
|
+
if part == "profiles" || part == "certs" ||
|
|
201
|
+
relevant_parts.length >= 3
|
|
202
|
+
break
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
relative_path = relevant_parts.join("/")
|
|
206
|
+
else
|
|
207
|
+
# Already a relative path
|
|
208
|
+
relative_path = file_path
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Convert the file path to the same flat secret name used during upload
|
|
212
|
+
# Sanitize the secret name by replacing problematic characters
|
|
213
|
+
secret_name = relative_path.gsub("/", "-").gsub(/[^\w\-\.]/, "_")
|
|
214
|
+
|
|
215
|
+
delete_secret(secret_name, "/fastlane")
|
|
216
|
+
|
|
217
|
+
UI.verbose("Deleted: #{relative_path} (#{secret_name})")
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def download_files(files_to_download, download_directory)
|
|
223
|
+
with_infisical_error_handling do
|
|
224
|
+
# Ensure download directory exists
|
|
225
|
+
FileUtils.mkdir_p(download_directory)
|
|
226
|
+
|
|
227
|
+
files_to_download.each do |relative_path|
|
|
228
|
+
# Convert the relative path to a flat secret name
|
|
229
|
+
# Sanitize the secret name by replacing problematic characters
|
|
230
|
+
secret_name = relative_path.gsub("/", "-").gsub(/[^\w\-\.]/, "_")
|
|
231
|
+
|
|
232
|
+
begin
|
|
233
|
+
# Get the secret from Infisical
|
|
234
|
+
secret =
|
|
235
|
+
@client.secrets.get(
|
|
236
|
+
secret_name: secret_name,
|
|
237
|
+
project_id: @project_id,
|
|
238
|
+
environment: @environment,
|
|
239
|
+
path: "/fastlane"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Decode and write the file
|
|
243
|
+
content = Base64.strict_decode64(secret["secretValue"])
|
|
244
|
+
|
|
245
|
+
full_path = File.join(download_directory, relative_path)
|
|
246
|
+
|
|
247
|
+
# Create directory if needed
|
|
248
|
+
FileUtils.mkdir_p(File.dirname(full_path))
|
|
249
|
+
|
|
250
|
+
# Write the file
|
|
251
|
+
File.binwrite(full_path, content)
|
|
252
|
+
|
|
253
|
+
UI.verbose("Downloaded: #{secret_name} -> #{relative_path}")
|
|
254
|
+
rescue InfisicalSDK::InfisicalError => e
|
|
255
|
+
UI.verbose("Secret not found: #{secret_name} (#{e.message})")
|
|
256
|
+
# Don't fail the entire operation for missing individual files
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
UI.success(
|
|
261
|
+
"Downloaded #{files_to_download.length} requested files from Infisical"
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def human_readable_description
|
|
267
|
+
"Infisical Storage (Project: #{@project_id}, Environment: #{@environment}, Path: #{@secret_path})"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def skip_docs
|
|
271
|
+
false
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def generate_matchfile_content(template: nil)
|
|
275
|
+
return ""
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
private
|
|
279
|
+
|
|
280
|
+
def authenticate_client
|
|
281
|
+
# Try Universal Auth first (preferred method)
|
|
282
|
+
client_id =
|
|
283
|
+
ENV["INFISICAL_CLIENT_ID"] ||
|
|
284
|
+
ENV["INFISICAL_UNIVERSAL_AUTH_CLIENT_ID"]
|
|
285
|
+
client_secret =
|
|
286
|
+
ENV["INFISICAL_CLIENT_SECRET"] ||
|
|
287
|
+
ENV["INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET"]
|
|
288
|
+
|
|
289
|
+
if client_id && client_secret
|
|
290
|
+
UI.verbose("🔐 Authenticating with Universal Auth...")
|
|
291
|
+
@client.auth.universal_auth(
|
|
292
|
+
client_id: client_id,
|
|
293
|
+
client_secret: client_secret
|
|
294
|
+
)
|
|
295
|
+
UI.success("✅ Universal Auth successful!")
|
|
296
|
+
return
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Fallback to service token (legacy)
|
|
300
|
+
service_token = ENV["INFISICAL_TOKEN"]
|
|
301
|
+
if service_token
|
|
302
|
+
UI.verbose("🔐 Using Service Token authentication...")
|
|
303
|
+
# Service tokens are used differently - they're passed as bearer tokens
|
|
304
|
+
# The SDK might handle this automatically, but we may need to set it manually
|
|
305
|
+
UI.important(
|
|
306
|
+
"⚠️ Service Token authentication with SDK - this may not work with E2EE enabled"
|
|
307
|
+
)
|
|
308
|
+
# Note: The SDK may not directly support service tokens in the same way
|
|
309
|
+
# This would need to be tested
|
|
310
|
+
return
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
UI.user_error!(
|
|
314
|
+
"No Infisical authentication method found. Please set either INFISICAL_CLIENT_ID + INFISICAL_CLIENT_SECRET or INFISICAL_TOKEN environment variables."
|
|
315
|
+
)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def list_fastlane_secrets
|
|
319
|
+
begin
|
|
320
|
+
# List all secrets in the /fastlane path
|
|
321
|
+
secrets =
|
|
322
|
+
@client.secrets.list(
|
|
323
|
+
project_id: @project_id,
|
|
324
|
+
environment: @environment,
|
|
325
|
+
path: "/fastlane"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
UI.verbose("Found #{secrets.length} secrets in /fastlane")
|
|
329
|
+
secrets
|
|
330
|
+
rescue => e
|
|
331
|
+
UI.verbose("No secrets found in /fastlane: #{e.message}")
|
|
332
|
+
[]
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def list_all_secrets
|
|
337
|
+
list_fastlane_secrets
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def create_or_update_secret(secret_name, value, path = nil)
|
|
341
|
+
begin
|
|
342
|
+
# Try to create first (simpler approach)
|
|
343
|
+
result =
|
|
344
|
+
if path && path != @secret_path
|
|
345
|
+
@client.secrets.create(
|
|
346
|
+
secret_name: secret_name,
|
|
347
|
+
secret_value: value,
|
|
348
|
+
project_id: @project_id,
|
|
349
|
+
environment: @environment,
|
|
350
|
+
path: path
|
|
351
|
+
)
|
|
352
|
+
else
|
|
353
|
+
@client.secrets.create(
|
|
354
|
+
secret_name: secret_name,
|
|
355
|
+
secret_value: value,
|
|
356
|
+
project_id: @project_id,
|
|
357
|
+
environment: @environment
|
|
358
|
+
)
|
|
359
|
+
end
|
|
360
|
+
UI.verbose(
|
|
361
|
+
"Created secret: #{secret_name} (path: #{path || "default"})"
|
|
362
|
+
)
|
|
363
|
+
result
|
|
364
|
+
rescue InfisicalSDK::InfisicalError => e
|
|
365
|
+
# If create fails, try to update
|
|
366
|
+
UI.verbose("Create failed: #{e.message}, trying update...")
|
|
367
|
+
begin
|
|
368
|
+
result =
|
|
369
|
+
if path && path != @secret_path
|
|
370
|
+
@client.secrets.update(
|
|
371
|
+
secret_name: secret_name,
|
|
372
|
+
secret_value: value,
|
|
373
|
+
project_id: @project_id,
|
|
374
|
+
environment: @environment,
|
|
375
|
+
path: path
|
|
376
|
+
)
|
|
377
|
+
else
|
|
378
|
+
@client.secrets.update(
|
|
379
|
+
secret_name: secret_name,
|
|
380
|
+
secret_value: value,
|
|
381
|
+
project_id: @project_id,
|
|
382
|
+
environment: @environment
|
|
383
|
+
)
|
|
384
|
+
end
|
|
385
|
+
UI.verbose(
|
|
386
|
+
"Updated secret: #{secret_name} (path: #{path || "default"})"
|
|
387
|
+
)
|
|
388
|
+
result
|
|
389
|
+
rescue InfisicalSDK::InfisicalError => update_error
|
|
390
|
+
UI.error(
|
|
391
|
+
"Failed to create/update secret #{secret_name}: #{e.message} (create), #{update_error.message} (update)"
|
|
392
|
+
)
|
|
393
|
+
raise update_error
|
|
394
|
+
end
|
|
395
|
+
rescue => e
|
|
396
|
+
UI.error("Unexpected error with secret #{secret_name}: #{e.message}")
|
|
397
|
+
raise e
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def delete_secret(secret_name, path = nil)
|
|
402
|
+
if path && path != @secret_path
|
|
403
|
+
@client.secrets.delete(
|
|
404
|
+
secret_name: secret_name,
|
|
405
|
+
project_id: @project_id,
|
|
406
|
+
environment: @environment,
|
|
407
|
+
path: path
|
|
408
|
+
)
|
|
409
|
+
else
|
|
410
|
+
@client.secrets.delete(
|
|
411
|
+
secret_name: secret_name,
|
|
412
|
+
project_id: @project_id,
|
|
413
|
+
environment: @environment
|
|
414
|
+
)
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def with_infisical_error_handling
|
|
419
|
+
explainer =
|
|
420
|
+
"Note: Infisical credentials should be set via environment variables. Set either INFISICAL_CLIENT_ID + INFISICAL_CLIENT_SECRET or INFISICAL_TOKEN."
|
|
421
|
+
yield
|
|
422
|
+
rescue StandardError => e
|
|
423
|
+
UI.error("Infisical error: #{e}.\n\n#{explainer}")
|
|
424
|
+
raise e
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|