envless-env 0.0.1
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/CHANGELOG.md +13 -0
- data/README.md +117 -0
- data/exe/envless-env +6 -0
- data/lib/envless/env/api.rb +35 -0
- data/lib/envless/env/binding.rb +26 -0
- data/lib/envless/env/cache.rb +54 -0
- data/lib/envless/env/cache_writer.rb +34 -0
- data/lib/envless/env/coerce.rb +53 -0
- data/lib/envless/env/credentials.rb +27 -0
- data/lib/envless/env/crypto.rb +35 -0
- data/lib/envless/env/decryptor.rb +35 -0
- data/lib/envless/env/environment_writer.rb +43 -0
- data/lib/envless/env/errors.rb +47 -0
- data/lib/envless/env/files.rb +44 -0
- data/lib/envless/env/injected.rb +50 -0
- data/lib/envless/env/isolated.rb +45 -0
- data/lib/envless/env/keyring.rb +54 -0
- data/lib/envless/env/load.rb +5 -0
- data/lib/envless/env/loader.rb +83 -0
- data/lib/envless/env/messages.rb +36 -0
- data/lib/envless/env/override.rb +17 -0
- data/lib/envless/env/rails.rb +21 -0
- data/lib/envless/env/serialisation.rb +50 -0
- data/lib/envless/env/settings.rb +39 -0
- data/lib/envless/env/store.rb +91 -0
- data/lib/envless/env/target.rb +19 -0
- data/lib/envless/env/types/collect.rb +59 -0
- data/lib/envless/env/types/command.rb +64 -0
- data/lib/envless/env/types/declared.rb +43 -0
- data/lib/envless/env/types/render.rb +52 -0
- data/lib/envless/env/types.rb +6 -0
- data/lib/envless/env/update_notice.rb +64 -0
- data/lib/envless/env/values.rb +90 -0
- data/lib/envless/env/version.rb +7 -0
- data/lib/envless/env.rb +63 -0
- data/sig/envless/env/loading.rbs +84 -0
- data/sig/envless/env/settings.rbs +84 -0
- data/sig/envless/env/state.rbs +70 -0
- data/sig/envless/env/types.rbs +56 -0
- data/sig/envless/env/values.rbs +75 -0
- data/sig/envless/env.rbs +66 -0
- data/sig/manifest.yaml +2 -0
- metadata +101 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3005e3dc4471d987d8960f6007d26aff6309c424a6f77cdfa5bc6150e7ea504c
|
|
4
|
+
data.tar.gz: 30efa0f02503e3524d6b84c7edadc3fa739998b1bb0bc68302bffa68b31c1eb9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2e158c360a4c7e057ec90651a1fa7fb24263b486071f812f37f253f9779cb5654c6b5ff32a614c71d72307fc9f4f177668042f6397ae3c898d2ee5181aba2410
|
|
7
|
+
data.tar.gz: b961eaaecfc259e2272da9f458d87ccb9d8ff76010cbf728c9f51332a0f1da1d700c36492bc4f7c973541337e6657e7bfeeb2b2fceb2d7ed4b3c9df1a875e6b9
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.0.1
|
|
4
|
+
|
|
5
|
+
The first release of the Ruby runtime, matching `@goenvless/env` as it stands in this repository. `rake parity` runs both packages through the same scenarios against one fake API and fails on any difference in the requests, the values, the types, the errors, the process environment, the warnings or the cache files.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
`Envless::Env.load` fetches an environment's ciphertext with an `ev_sk_` machine key, decrypts it with `ENVLESS_KEY` or `ENVLESS_PASSPHRASE`, caches the ciphertext for 15 minutes in the files `@goenvless/env` uses, and fills `ENV` without overwriting anything already set or writing a name that changes how a process executes code, `RUBYOPT`, `RUBYLIB`, `BUNDLE_GEMFILE`, `GEM_HOME` and `GEM_PATH` included. Inside `envless run` it adopts the injected values with no credentials and no request. `require "envless/env/load"` loads at require.
|
|
10
|
+
|
|
11
|
+
`Envless.env` reads every variable coerced to its type, `optional` reads `nil` for a missing name, and `client` sees only the client variables, with `snapshot` for templates. The views refuse to be dumped, marshalled or printed with their values. `Envless::Env.override` layers values for tests.
|
|
12
|
+
|
|
13
|
+
`require "envless/env/rails"` adds a Railtie that loads before the app is configured and an `envless` view helper. `Envless::Env.fetch_environment` loads with explicit keywords for serverless functions, and the crypto helpers carry the encryption recipe. Every method has an RBS signature, and `bundle exec envless-env types` writes `sig/envless-env.rbs` from your variables, so Steep types every read by name.
|
data/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<div align='center'>
|
|
2
|
+
<a href='https://envless.cloud'>
|
|
3
|
+
<img
|
|
4
|
+
src='https://cdn-prod.envless.cloud/assets/npm/logo.svg'
|
|
5
|
+
alt='Envless Logo'
|
|
6
|
+
width='180'
|
|
7
|
+
/>
|
|
8
|
+
</a>
|
|
9
|
+
|
|
10
|
+
<br />
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<p align='center'>
|
|
14
|
+
End-to-end encrypted environment variables for teams. Load them into a Ruby app at boot, decrypted on your machine.
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<p align='center'>
|
|
18
|
+
<a href='https://envless.cloud'>
|
|
19
|
+
<b>
|
|
20
|
+
Website
|
|
21
|
+
</b>
|
|
22
|
+
</a>
|
|
23
|
+
•
|
|
24
|
+
<a href='https://envless.cloud/dashboard'>
|
|
25
|
+
<b>
|
|
26
|
+
Dashboard
|
|
27
|
+
</b>
|
|
28
|
+
</a>
|
|
29
|
+
•
|
|
30
|
+
<a href='https://envless.cloud/docs/env-ruby/overview'>
|
|
31
|
+
<b>
|
|
32
|
+
Documentation
|
|
33
|
+
</b>
|
|
34
|
+
</a>
|
|
35
|
+
•
|
|
36
|
+
<a href='https://status.envless.cloud'>
|
|
37
|
+
<b>
|
|
38
|
+
Services Status
|
|
39
|
+
</b>
|
|
40
|
+
</a>
|
|
41
|
+
</p>
|
|
42
|
+
|
|
43
|
+
<br />
|
|
44
|
+
|
|
45
|
+
## Intro to the Ruby runtime
|
|
46
|
+
|
|
47
|
+
`envless-env` loads your Envless environment into a Ruby process when it starts. It fetches the ciphertext with a machine key, decrypts it in the process, fills `ENV` with every name that is safe to set, and gives your code `Envless.env`, which reads each variable coerced to the type you set in Envless. It is the Ruby twin of `@goenvless/env`: the same loader, the same on-disk cache and the same rules for the process environment.
|
|
48
|
+
|
|
49
|
+
### Installing
|
|
50
|
+
```bash
|
|
51
|
+
bundle add envless-env
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or `gem install envless-env`. It needs Ruby 3.2 or newer and depends on `envless-sdk` only.
|
|
55
|
+
|
|
56
|
+
### Loading
|
|
57
|
+
```ruby
|
|
58
|
+
require "envless/env/load"
|
|
59
|
+
|
|
60
|
+
database_url = Envless.env[:DATABASE_URL]
|
|
61
|
+
port = Envless.env[:PORT]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`envless/env/load` loads when it is required, so require it first. `require "envless/env"` loads nothing until you call `Envless::Env.load`, which returns a plain Hash of every value and is memoised, so later calls return the same values without fetching. `Envless::Env.load(revalidate: true)` fetches again.
|
|
65
|
+
|
|
66
|
+
The loader reads `ENVLESS_TOKEN` (an `ev_sk_` machine key), `ENVLESS_KEY` or `ENVLESS_PASSPHRASE` to decrypt, and the product, project and environment from `ENVLESS_PRODUCT`, `ENVLESS_PROJECT` and `ENVLESS_ENV` or the `.envless` file `envless link` writes. The ciphertext is cached under `~/.envless/cache` for 15 minutes, in the same files `@goenvless/env` uses. Inside `envless run`, everything comes from the environment the CLI injected, with no credentials and no request.
|
|
67
|
+
|
|
68
|
+
Values are written into `ENV` without overwriting anything already set, and names that change how a process executes code, such as `PATH`, `LD_PRELOAD`, `RUBYOPT` and `RUBYLIB`, are never written there at all.
|
|
69
|
+
|
|
70
|
+
### Reading
|
|
71
|
+
```ruby
|
|
72
|
+
require "envless/env/load"
|
|
73
|
+
|
|
74
|
+
port = Envless.env.fetch(:PORT, 3000)
|
|
75
|
+
sentry_dsn = Envless.env.optional[:SENTRY_DSN]
|
|
76
|
+
public_values = Envless.env.client.snapshot
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
A `number` reads back as an Integer, or a Float when it has a fraction, and a `boolean` as `true` or `false`. A missing name raises `Envless::Env::MissingVariableError` unless you read it through `optional`. The `client` view only sees the variables marked client in Envless. `Envless.env` never prints its values and refuses to be dumped or marshalled.
|
|
80
|
+
|
|
81
|
+
### Types
|
|
82
|
+
```bash
|
|
83
|
+
bundle exec envless-env types
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Writes `sig/envless-env.rbs` from your variable names, types and visibility, so Steep types `Envless.env.DATABASE_URL` as a `String` and catches a misspelt name or a server variable read through `client`. Under `envless run` it reads the injected manifest and needs no key.
|
|
87
|
+
|
|
88
|
+
### Rails
|
|
89
|
+
```ruby
|
|
90
|
+
gem "envless-env", require: "envless/env/rails"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The Railtie loads before the app is configured, so `config/database.yml` and every initializer see the values, and views get an `envless` helper with the client variables.
|
|
94
|
+
|
|
95
|
+
### Tests
|
|
96
|
+
```ruby
|
|
97
|
+
require "envless/env"
|
|
98
|
+
|
|
99
|
+
Envless::Env.override(API_URL: "https://api.example.test") do
|
|
100
|
+
puts Envless.env[:API_URL]
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Serverless
|
|
105
|
+
```ruby
|
|
106
|
+
require "envless/env"
|
|
107
|
+
|
|
108
|
+
values = Envless::Env.fetch_environment(token: ENV.fetch("ENVLESS_TOKEN"),
|
|
109
|
+
project: "checkout-api", environment: "production",
|
|
110
|
+
key: ENV.fetch("ENVLESS_KEY"))
|
|
111
|
+
|
|
112
|
+
puts values.size
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`fetch_environment` takes every input as a keyword and touches nothing on disk or in `ENV`.
|
|
116
|
+
|
|
117
|
+
Every load failure raises `Envless::Env::LoadError`, and every error inherits `Envless::Error` from `envless-sdk`. The gem ships RBS signatures: add `library "envless-sdk", "envless-env"` to your Steepfile.
|
data/exe/envless-env
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
class Api
|
|
6
|
+
FAILURES = [Envless::Error, ArgumentError].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(token, endpoint: nil, adapter: nil)
|
|
9
|
+
@client = Envless::Client.new(token:, base_url: endpoint, adapter:, user_agent: Settings::USER_AGENT,
|
|
10
|
+
disable_update_notice: true)
|
|
11
|
+
rescue *FAILURES => error
|
|
12
|
+
raise LoadError, error.message
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def workspace_id
|
|
16
|
+
id = @client.workspace.get[:id]
|
|
17
|
+
id.is_a?(String) ? id : nil
|
|
18
|
+
rescue *FAILURES => error
|
|
19
|
+
raise LoadError, error.message
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def variables(target)
|
|
23
|
+
listed = @client.variables.list_all(target.project, target.environment, product: target.product,
|
|
24
|
+
limit: Settings::PAGE_LIMIT)
|
|
25
|
+
listed.map do |variable|
|
|
26
|
+
{name: variable[:name], value: variable[:value] || "", type: variable[:type], visibility: variable[:visibility]}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def close
|
|
31
|
+
@client.close
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
Binding = Data.define(:workspace, :product, :project, :environments)
|
|
6
|
+
|
|
7
|
+
class Binding
|
|
8
|
+
def self.read(directory = Dir.pwd)
|
|
9
|
+
parsed = Files.json(File.join(directory, Settings::ENVLESS_FILE))
|
|
10
|
+
return nil if parsed.nil?
|
|
11
|
+
|
|
12
|
+
workspace = parsed["workspace"]
|
|
13
|
+
project = parsed["project"]
|
|
14
|
+
environments = parsed["environments"]
|
|
15
|
+
return nil unless Files.text?(workspace) && Files.text?(project) && listed?(environments)
|
|
16
|
+
|
|
17
|
+
product = parsed["product"]
|
|
18
|
+
new(workspace:, product: Files.text?(product) ? product : nil, project:, environments:)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.listed?(environments)
|
|
22
|
+
environments.is_a?(Array) && !environments.empty? && environments.all? { |slug| Files.text?(slug) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
module Cache
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def path(target)
|
|
9
|
+
directory = Files.config_dir
|
|
10
|
+
return nil if directory.nil?
|
|
11
|
+
|
|
12
|
+
product = target.product ? sanitize(target.product) : Settings::CACHE_NO_PRODUCT
|
|
13
|
+
name = "#{product}__#{sanitize(target.project)}__#{sanitize(target.environment)}.json"
|
|
14
|
+
name = "#{sanitize(target.workspace)}__#{name}" if target.workspace
|
|
15
|
+
File.join(directory, Settings::CACHE_DIR, name)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def sanitize(slug)
|
|
19
|
+
Files.utf8(slug).gsub(Settings::CACHE_UNSAFE_CHARACTERS) { |character| (character.ord > 0xFFFF) ? "__" : "_" }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def read(target)
|
|
23
|
+
location = path(target)
|
|
24
|
+
parsed = location && Files.json(location)
|
|
25
|
+
return nil if parsed.nil? || !parsed["variables"].is_a?(Array)
|
|
26
|
+
|
|
27
|
+
variables = parsed["variables"].map { |entry| entry_of(entry) }
|
|
28
|
+
return nil if variables.any?(&:nil?)
|
|
29
|
+
|
|
30
|
+
workspace = parsed["workspace"].is_a?(String) ? parsed["workspace"] : nil
|
|
31
|
+
return nil unless workspace == target.workspace
|
|
32
|
+
|
|
33
|
+
{variables: variables.compact, cached_at: parsed["cachedAt"].is_a?(String) ? parsed["cachedAt"] : ""}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def entry_of(entry)
|
|
37
|
+
return nil unless entry.is_a?(Hash) && entry["name"].is_a?(String) && entry["value"].is_a?(String)
|
|
38
|
+
|
|
39
|
+
type = entry["type"]
|
|
40
|
+
visibility = entry["visibility"]
|
|
41
|
+
{name: entry["name"], value: entry["value"], type: type.is_a?(String) ? type : nil,
|
|
42
|
+
visibility: visibility.is_a?(String) ? visibility : nil}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fresh?(cached_at, now = Time.now)
|
|
46
|
+
stamped = Time.iso8601(cached_at)
|
|
47
|
+
age = now - stamped
|
|
48
|
+
age >= 0 && age < Settings::CACHE_MAX_AGE
|
|
49
|
+
rescue ArgumentError
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
module CacheWriter
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def write(target, variables, now = Time.now)
|
|
9
|
+
location = Cache.path(target)
|
|
10
|
+
return if location.nil?
|
|
11
|
+
|
|
12
|
+
directory = File.dirname(location)
|
|
13
|
+
FileUtils.mkdir_p(directory, mode: Settings::DIR_MODE)
|
|
14
|
+
File.chmod(Settings::DIR_MODE, File.dirname(directory))
|
|
15
|
+
File.chmod(Settings::DIR_MODE, directory)
|
|
16
|
+
replace(location, JSON.pretty_generate(payload(target, variables, now)))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def payload(target, variables, now)
|
|
20
|
+
entries = variables.map { |variable| variable.slice(:name, :value, :type, :visibility).compact }
|
|
21
|
+
written = {variables: entries, cachedAt: now.utc.iso8601(3)}
|
|
22
|
+
target.workspace ? written.merge(workspace: target.workspace) : written
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def replace(location, content)
|
|
26
|
+
staged = "#{location}.tmp"
|
|
27
|
+
File.open(staged, File::WRONLY | File::CREAT | File::TRUNC, Settings::FILE_MODE) { |file| file.write(content) }
|
|
28
|
+
File.chmod(Settings::FILE_MODE, staged)
|
|
29
|
+
File.rename(staged, location)
|
|
30
|
+
File.chmod(Settings::FILE_MODE, location)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
module Coerce
|
|
6
|
+
WHITESPACE = "\t\n\v\f\r -
"
|
|
7
|
+
DECIMAL = /\A[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\z/
|
|
8
|
+
INFINITY = /\A([+-]?)Infinity\z/
|
|
9
|
+
RADIX = /\A0([xXoObB])([0-9a-fA-F]+)\z/
|
|
10
|
+
BASES = {"x" => 16, "o" => 8, "b" => 2}.freeze
|
|
11
|
+
TRUE_VALUES = %w[true 1].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def call(value, type)
|
|
16
|
+
case type
|
|
17
|
+
when Settings::VAR_TYPES[:NUMBER] then number(value)
|
|
18
|
+
when Settings::VAR_TYPES[:BOOLEAN] then TRUE_VALUES.include?(value)
|
|
19
|
+
else value
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def number(value)
|
|
24
|
+
text = trim(value)
|
|
25
|
+
return 0 if text.empty?
|
|
26
|
+
|
|
27
|
+
infinity = INFINITY.match(text)
|
|
28
|
+
return (infinity[1] == "-") ? -Float::INFINITY : Float::INFINITY if infinity
|
|
29
|
+
|
|
30
|
+
radix = RADIX.match(text)
|
|
31
|
+
return radix_number(radix[2], BASES.fetch(radix[1].downcase)) if radix
|
|
32
|
+
return Float::NAN unless DECIMAL.match?(text)
|
|
33
|
+
|
|
34
|
+
integral(text.sub(/\.(?!\d)/, ".0").to_f, text)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def trim(text)
|
|
38
|
+
text.gsub(/\A[#{WHITESPACE}]+|[#{WHITESPACE}]+\z/o, "")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def radix_number(digits, base)
|
|
42
|
+
Integer(digits, base, exception: false) || Float::NAN
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def integral(parsed, text)
|
|
46
|
+
return parsed unless parsed.finite? && parsed == parsed.floor
|
|
47
|
+
return Integer(text.delete_prefix("+"), 10) if text.match?(/\A[+-]?\d+\z/)
|
|
48
|
+
|
|
49
|
+
parsed.to_i
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
module Credentials
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def token
|
|
9
|
+
from_env = Files.variable(Settings::ENV_VARS[:TOKEN])
|
|
10
|
+
return from_env if Files.text?(from_env)
|
|
11
|
+
|
|
12
|
+
directory = Files.config_dir
|
|
13
|
+
stored = directory && Files.json(File.join(directory, Settings::CONFIG_FILE))
|
|
14
|
+
candidate = stored && stored["token"]
|
|
15
|
+
Files.text?(candidate) ? candidate : nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def machine_token
|
|
19
|
+
resolved = token
|
|
20
|
+
raise LoadError, Messages::TOKEN_MISSING if resolved.nil?
|
|
21
|
+
raise LoadError, Messages::TOKEN_NOT_MACHINE unless resolved.start_with?(Settings::MACHINE_TOKEN_PREFIX)
|
|
22
|
+
|
|
23
|
+
resolved
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
module Crypto
|
|
6
|
+
def encrypt(plaintext, passphrase, workspace_unique_id)
|
|
7
|
+
Envless.encrypt_value(plaintext, passphrase, workspace_unique_id)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def decrypt(ciphertext, passphrase, workspace_unique_id)
|
|
11
|
+
Envless.decrypt_value(ciphertext, passphrase, workspace_unique_id)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def encrypt_with_key(key, plaintext)
|
|
15
|
+
Envless.encrypt_with_key(key, plaintext)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def decrypt_with_key(key, ciphertext)
|
|
19
|
+
Envless.decrypt_with_key(key, ciphertext)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def workspace_key(passphrase, workspace_unique_id)
|
|
23
|
+
Envless.derive_workspace_key(passphrase, workspace_unique_id)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def import_workspace_key(material)
|
|
27
|
+
Envless.import_workspace_key(material)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def passphrase_strength(passphrase)
|
|
31
|
+
Envless.passphrase_strength(passphrase)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
module Decryptor
|
|
6
|
+
Outcome = Data.define(:entries, :failed)
|
|
7
|
+
FAILURES = [Envless::Error, ArgumentError].freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def call(variables, keyring)
|
|
12
|
+
# @type var entries: Array[[String, String]]
|
|
13
|
+
entries = []
|
|
14
|
+
variables.each do |variable|
|
|
15
|
+
name = variable[:name]
|
|
16
|
+
value = variable[:value]
|
|
17
|
+
next unless value.is_a?(String)
|
|
18
|
+
next entries << [name, ""] if value.empty?
|
|
19
|
+
|
|
20
|
+
plaintext = attempt(keyring, value)
|
|
21
|
+
return Outcome.new(entries: nil, failed: name) if plaintext.nil?
|
|
22
|
+
|
|
23
|
+
entries << [name, plaintext]
|
|
24
|
+
end
|
|
25
|
+
Outcome.new(entries:, failed: nil)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def attempt(keyring, value)
|
|
29
|
+
keyring.decrypt(value)
|
|
30
|
+
rescue *FAILURES
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
class EnvironmentWriter
|
|
6
|
+
def initialize
|
|
7
|
+
@owned = []
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def write(record)
|
|
11
|
+
previous = @owned
|
|
12
|
+
previous.each { |name| ENV.delete(name) unless record.key?(name) }
|
|
13
|
+
# @type var refused: Array[String]
|
|
14
|
+
refused = []
|
|
15
|
+
# @type var unwritable: Array[String]
|
|
16
|
+
unwritable = []
|
|
17
|
+
# @type var owned: Array[String]
|
|
18
|
+
owned = []
|
|
19
|
+
record.each do |name, value|
|
|
20
|
+
next refused << name if Settings::UNSAFE_NAMES.include?(name.downcase)
|
|
21
|
+
next if ENV.key?(name) && !previous.include?(name)
|
|
22
|
+
next unwritable << name unless writable?(name, value)
|
|
23
|
+
|
|
24
|
+
ENV[name] = value
|
|
25
|
+
owned << name
|
|
26
|
+
end
|
|
27
|
+
@owned = owned
|
|
28
|
+
report(refused, unwritable)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def writable?(name, value)
|
|
34
|
+
!name.empty? && !name.include?("=") && !name.include?("\0") && !value.include?("\0")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def report(refused, unwritable)
|
|
38
|
+
warn format(Messages::UNSAFE_REFUSED, names: refused.join(", ")) unless refused.empty?
|
|
39
|
+
warn format(Messages::UNWRITABLE, names: unwritable.join(", ")) unless unwritable.empty?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
class Error < Envless::Error
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
class LoadError < Error
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class NotLoadedError < Error
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class VariableError < Error
|
|
15
|
+
attr_reader :variable
|
|
16
|
+
|
|
17
|
+
def initialize(variable, template)
|
|
18
|
+
@variable = variable
|
|
19
|
+
super(format(template, name: variable))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class MissingVariableError < VariableError
|
|
24
|
+
def initialize(variable)
|
|
25
|
+
super(variable, Messages::MISSING_VARIABLE)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class ServerOnlyError < VariableError
|
|
30
|
+
def initialize(variable)
|
|
31
|
+
super(variable, Messages::SERVER_ONLY)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class ReadOnlyError < Error
|
|
36
|
+
def initialize(message = Messages::READ_ONLY)
|
|
37
|
+
super
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class SerialisationError < Error
|
|
42
|
+
def initialize(message = Messages::NOT_SERIALISABLE)
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
module Files
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def json(path)
|
|
9
|
+
return nil unless File.file?(path)
|
|
10
|
+
|
|
11
|
+
parsed = JSON.parse(File.read(path, encoding: Encoding::UTF_8))
|
|
12
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
13
|
+
rescue JSON::ParserError, SystemCallError, IOError, EncodingError
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def home
|
|
18
|
+
Dir.home
|
|
19
|
+
rescue ArgumentError
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def config_dir
|
|
24
|
+
root = home
|
|
25
|
+
root && File.join(root, Settings::CONFIG_DIR)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def text?(value)
|
|
29
|
+
value.is_a?(String) && !value.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def variable(name)
|
|
33
|
+
value = ENV.fetch(name, nil)
|
|
34
|
+
value && utf8(value)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def utf8(text)
|
|
38
|
+
return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
|
|
39
|
+
|
|
40
|
+
text.dup.force_encoding(Encoding::UTF_8).scrub
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
module Injected
|
|
6
|
+
TYPES = Settings::VAR_TYPES.values.freeze
|
|
7
|
+
VISIBILITIES = Settings::VISIBILITY.values.freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def seed
|
|
12
|
+
raw = Files.variable(Settings::INJECTED[:NAME])
|
|
13
|
+
return nil if raw.nil? || raw.empty?
|
|
14
|
+
|
|
15
|
+
manifest = parse(raw)
|
|
16
|
+
return nil unless manifest.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
# @type var record: Hash[String, String]
|
|
19
|
+
record = {}
|
|
20
|
+
# @type var meta: Hash[String, meta]
|
|
21
|
+
meta = {}
|
|
22
|
+
manifest.each do |name, entry|
|
|
23
|
+
next unless name.is_a?(String)
|
|
24
|
+
|
|
25
|
+
value = Files.variable(name)
|
|
26
|
+
next if value.nil? || Settings::UNSAFE_NAMES.include?(name.downcase)
|
|
27
|
+
|
|
28
|
+
record[name] = value
|
|
29
|
+
meta[name] = meta_of(entry)
|
|
30
|
+
end
|
|
31
|
+
record.empty? ? nil : [record, meta]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse(raw)
|
|
35
|
+
JSON.parse(raw)
|
|
36
|
+
rescue JSON::ParserError, EncodingError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def meta_of(entry)
|
|
41
|
+
return {type: Settings::DEFAULT_TYPE, visibility: Settings::DEFAULT_VISIBILITY} unless entry.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
type = entry[Settings::INJECTED[:TYPE_KEY]]
|
|
44
|
+
visibility = entry[Settings::INJECTED[:VISIBILITY_KEY]]
|
|
45
|
+
{type: TYPES.include?(type) ? type : Settings::DEFAULT_TYPE,
|
|
46
|
+
visibility: VISIBILITIES.include?(visibility) ? visibility : Settings::DEFAULT_VISIBILITY}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
module Isolated
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def call(token:, project:, environment:, product: nil, passphrase: nil, key: nil, workspace_id: nil, endpoint: nil,
|
|
9
|
+
adapter: nil)
|
|
10
|
+
raise LoadError, Messages::ISOLATED_NO_TOKEN unless Files.text?(token)
|
|
11
|
+
raise LoadError, Messages::ISOLATED_NO_TARGET unless Files.text?(project) && Files.text?(environment)
|
|
12
|
+
raise LoadError, Messages::ISOLATED_NO_KEY_MATERIAL unless Files.text?(passphrase) || Files.text?(key)
|
|
13
|
+
|
|
14
|
+
api = Api.new(token, endpoint:, adapter:)
|
|
15
|
+
begin
|
|
16
|
+
decrypted(api, keyring_of(api, passphrase, key, workspace_id), product, project, environment)
|
|
17
|
+
ensure
|
|
18
|
+
api.close
|
|
19
|
+
end
|
|
20
|
+
rescue Envless::Error, ArgumentError => error
|
|
21
|
+
raise error if error.is_a?(LoadError)
|
|
22
|
+
|
|
23
|
+
raise LoadError, error.message
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def decrypted(api, keyring, product, project, environment)
|
|
27
|
+
target = Target.new(workspace: nil, product: Files.text?(product) ? product : nil, project:, environment:)
|
|
28
|
+
outcome = Decryptor.call(api.variables(target), keyring)
|
|
29
|
+
entries = outcome.entries
|
|
30
|
+
raise LoadError, format(Messages::ISOLATED_DECRYPT_FAILED, name: outcome.failed) if entries.nil?
|
|
31
|
+
|
|
32
|
+
entries.to_h
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def keyring_of(api, passphrase, key, workspace_id)
|
|
36
|
+
return Keyring.imported(key) if key && !key.empty?
|
|
37
|
+
|
|
38
|
+
workspace = (workspace_id && !workspace_id.empty?) ? workspace_id : api.workspace_id
|
|
39
|
+
raise LoadError, Messages::ISOLATED_NO_WORKSPACE if workspace.nil? || workspace.empty?
|
|
40
|
+
|
|
41
|
+
Keyring.derived(passphrase.to_s, workspace)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|