diff --git a/src/AWSConfig.jl b/src/AWSConfig.jl new file mode 100644 index 0000000..2a94755 --- /dev/null +++ b/src/AWSConfig.jl @@ -0,0 +1,209 @@ +""" + AWSConfig + +Most `AWSCore` functions take an `AWSConfig` object as the first argument. +This type holds [`AWSCredentials`](@ref), region, and output configuration. + +# Constructors + + AWSConfig(; profile, creds, region, output) + +Construct an `AWSConfig` object with the given profile, credentials, region, and output +format. All keyword arguments have default values and are thus optional. + +* `profile`: Profile name passed to [`AWSCredentials`](@ref), or `nothing` (default) +* `creds`: `AWSCredentials` object, constructed using `profile` if not provided +* `region`: Region, read from `AWS_DEFAULT_REGION` if present, otherwise `"us-east-1"` +* `output`: Output format, defaulting to JSON (`"json"`) + +# Examples + +```julia-repl +julia> AWSConfig(profile="example", region="ap-southeast-2") +AWSConfig(creds=AWSCredentials("AKIDEXAMPLE", "wJa..."), region="ap-southeast-2", output="json") +``` +""" +mutable struct AWSConfig <: AbstractDict{Symbol,Any} # XXX: Remove subtype after deprecation + creds::AWSCredentials + region::String + output::String + # XXX: The `_extras` field will be removed after the deprecation period + _extras::Dict{Symbol,Any} +end + +function AWSConfig(; profile=nothing, + creds=AWSCredentials(profile=profile), + region=get(ENV, "AWS_DEFAULT_REGION", "us-east-1"), + output="json", + kwargs...) + AWSConfig(creds, region, output, kwargs) +end + +function Base.show(io::IO, conf::AWSConfig) + print(io, "AWSConfig(creds=AWSCredentials(") + show(io, conf.creds.access_key_id) + print(io, ", \"", conf.creds.secret_key[1:3], "...\"), region=") + show(io, conf.region) + print(io, ", output=") + show(io, conf.output) + print(io, ')') + if !isempty(conf._extras) + println(io, "\n Additional contents (SHOULD BE REMOVED):") + join(io, [string(" :", k, " => ", v) for (k, v) in conf._extras], '\n') + end +end + +# Overrides needed because of the AbstractDict subtyping +Base.summary(io::IO, conf::AWSConfig) = "AWSConfig" +Base.show(io::IO, ::MIME{Symbol("text/plain")}, conf::AWSConfig) = show(io, conf) + +function Base.Dict(conf::AWSConfig) + d = copy(conf._extras) + for f in [:creds, :region, :output] + d[f] = getfield(conf, f) + end + d +end + +# TODO: Implement copy for AWSCredentials +Base.copy(conf::AWSConfig) = AWSConfig(conf.creds, conf.region, conf.output, copy(conf._extras)) + +# Relics of using `SymbolDict`. We'll implement the entire `AbstractDict` interface +# with informative deprecation messages depending on how the functions are used: +# if users are storing and accessing the information that corresponds to the fields +# of the type, pretend like we're just using `@deprecate`. If they try it with other +# information, tell them it won't be possible soon. + +_isfield(x::Symbol) = (x === :creds || x === :region || x === :output) + +function _depmsg(store::Bool) + if store + verb = "storing" + preposition = "in" + else + verb = "retrieving" + preposition = "from" + end + string(verb, " information other than credentials, region, and output format ", + preposition, " an `AWSConfig` object is deprecated; use another data ", + "structure to store this information.") +end + +function _depsig(old::String, new::String="") + s = "`" * old * "` is deprecated" + if isempty(new) + s *= "; in the future, no information other than credentials, region, and output " * + "format will be stored in an `AWSConfig` object." + else + s *= ", use `" * new * "` instead." + end + s +end + +using Base: @deprecate, depwarn +import Base: merge, merge!, keytype, valtype + +@deprecate AWSConfig(pairs::Pair...) AWSConfig(; pairs...) +@deprecate aws_config AWSConfig +@deprecate merge(d::AbstractDict, conf::AWSConfig) merge(d, Dict(conf)) +@deprecate merge!(d::AbstractDict, conf::AWSConfig) merge!(d, Dict(conf)) +@deprecate keytype(conf::AWSConfig) Symbol +@deprecate valtype(conf::AWSConfig) Any + +function Base.setindex!(conf::AWSConfig, val, var::Symbol) + if _isfield(var) + depwarn(_depsig("setindex!(conf::AWSConfig, val, var::Symbol", + "setfield!(conf, var, val)"), :setindex!) + setfield!(conf, var, val) + else + depwarn(_depmsg(true), :setindex!) + conf._extras[var] = val + end +end + +function Base.getindex(conf::AWSConfig, x::Symbol) + if _isfield(x) + depwarn(_depsig("getindex(conf::AWSConfig, x::Symbol)", + "getfield(conf, x)"), :getindex) + getfield(conf, x) + else + depwarn(_depmsg(false), :getindex) + conf._extras[x] + end +end + +function Base.get(conf::AWSConfig, field::Symbol, alternative) + if _isfield(field) + depwarn(_depsig("get(conf::AWSConfig, field::Symbol, alternative)", + "getfield(conf, field)"), :get) + getfield(conf, field) + else + depwarn(_depmsg(false), :get) + get(conf._extras, field, alternative) + end +end + +function Base.haskey(conf::AWSConfig, field::Symbol) + depwarn(_depsig("haskey(conf::AWSConfig, field::Symbol)"), :haskey) + _isfield(field) || haskey(conf._extras, field) +end + +function Base.keys(conf::AWSConfig) + depwarn(_depsig("keys(conf::AWSConfig)"), :keys) + keys(Dict(conf)) +end + +function Base.values(conf::AWSConfig) + depwarn(_depsig("values(conf::AWSConfig)"), :values) + values(Dict(conf)) +end + +function Base.merge(conf::AWSConfig, d::AbstractDict{Symbol,<:Any}) + c = copy(conf) + for (k, v) in d + if _isfield(k) + depwarn("`merge(conf::AWSConf, d::AbstractDict)` is deprecated, set fields " * + "directly instead.", :merge) + setfield!(c, k, v) + else + Base.depwarn(_depmsg(true), :merge) + c._extras[k] = v + end + end + c +end + +function Base.merge!(conf::AWSConfig, d::AbstractDict{Symbol,<:Any}) + for (k, v) in d + if _isfield(k) + depwarn("`merge!(conf::AWSConf, d::AbstractDict)` is deprecated, set fields " * + "directly instead.", :merge!) + setfield!(conf, k, v) + else + depwarn(_depmsg(true), :merge!) + conf._extras[k] = v + end + end + conf +end + +function Base.iterate(conf::AWSConfig, state...) + depwarn("in the future, `AWSConfig` objects will not be iterable.", :iterate) + iterate(Dict(conf), state...) +end + +function Base.push!(conf::AWSConfig, (k, v)::Pair{Symbol,<:Any}) + if _isfield(conf, k) + depwarn(_depsig("push!(conf::AWSConfig, p::Pair)", + "setfield!(conf, first(p), last(p))"), :push!) + setfield!(conf, k, v) + else + Base.depwarn(_depmsg(true), :push!) + push!(conf._extras, k => v) + end +end + +function Base.in((k, v)::Pair, conf::AWSConfig) + depwarn("`in(p::Pair, conf::AWSConfig)` is deprecated.", :in) + (_isfield(k) && getfield(conf, k) == v) || in(p, conf._extras) +end diff --git a/src/AWSCore.jl b/src/AWSCore.jl index 0f66cc5..5d79512 100644 --- a/src/AWSCore.jl +++ b/src/AWSCore.jl @@ -22,17 +22,65 @@ using DataStructures: OrderedDict using JSON using LazyJSON - +# NOTE: This needs to be defined before AWSConfig. Methods defined on AWSCredentials are +# in src/AWSCredentials.jl. """ -Most `AWSCore` functions take a `AWSConfig` dictionary as the first argument. -This dictionary holds [`AWSCredentials`](@ref) and AWS region configuration. + AWSCredentials + +A type which holds AWS credentials. +When you interact with AWS, you specify your +[AWS Security Credentials](http://docs.aws.amazon.com/general/latest/gr/aws-security-credentials.html) +to verify who you are and whether you have permission to access the resources that you are +requesting. AWS uses the security credentials to authenticate and authorize your requests. + +The fields `access_key_id` and `secret_key` hold the access keys used to authenticate API +requests (see [Creating, Modifying, and Viewing Access +Keys](http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html#Using_CreateAccessKey)). + +[Temporary Security Credentials](http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp.html) +require the extra session `token` field. + +The `user_arn` and `account_number` fields are used to cache the result of the +[`aws_user_arn`](@ref) and [`aws_account_number`](@ref) functions. + +The `AWSCredentials()` constructor tries to load local credentials from: + +* `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` + [environment variables](http://docs.aws.amazon.com/cli/latest/userguide/cli-environment.html), +* [`~/.aws/credentials`](http://docs.aws.amazon.com/cli/latest/userguide/cli-config-files.html), or +* [EC2 Instance Credentials](http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#instance-metadata-security-credentials). -```julia -aws = AWSConfig(:creds => AWSCredentials(), :region => "us-east-1")` +To specify the profile to use from `~/.aws/credentials`, do, for example, +`AWSCredentials(profile="profile-name")`. + +A `~/.aws/credentials` file can be created using the +[AWS CLI](https://aws.amazon.com/cli/) command `aws configrue`. +Or it can be created manually: + +```ini +[default] +aws_access_key_id = AKIAXXXXXXXXXXXXXXXX +aws_secret_access_key = XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ``` + +If your `~/.aws/credentials` file contains multiple profiles you can pass the +profile name as a string to the `profile` keyword argument (`nothing` by +default) or select a profile by setting the `AWS_PROFILE` environment variable. """ -const AWSConfig = SymbolDict +mutable struct AWSCredentials + access_key_id::String + secret_key::String + token::String + user_arn::String + account_number::String + + function AWSCredentials(access_key_id, secret_key, + token="", user_arn="", account_number="") + new(access_key_id, secret_key, token, user_arn, account_number) + end +end +include("AWSConfig.jl") """ The `AWSRequest` dictionary describes a single API request: @@ -57,78 +105,24 @@ include("names.jl") include("mime.jl") - #------------------------------------------------------------------------------# # Configuration. #------------------------------------------------------------------------------# -""" -The `aws_config` function provides a simple way to creates an -[`AWSConfig`](@ref) configuration dictionary. - -```julia ->aws = aws_config() ->aws = aws_config(creds = my_credentials) ->aws = aws_config(region = "ap-southeast-2") ->aws = aws_config(profile = "profile-name") -``` - -By default, the `aws_config` attempts to load AWS credentials from: - - - `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` [environemnt variables](http://docs.aws.amazon.com/cli/latest/userguide/cli-environment.html), - - [`~/.aws/credentials`](http://docs.aws.amazon.com/cli/latest/userguide/cli-config-files.html) or - - [EC2 Instance Credentials](http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#instance-metadata-security-credentials). - -A `~/.aws/credentials` file can be created using the -[AWS CLI](https://aws.amazon.com/cli/) command `aws configrue`. -Or it can be created manually: - -```ini -[default] -aws_access_key_id = AKIAXXXXXXXXXXXXXXXX -aws_secret_access_key = XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -``` - -If your `~/.aws/credentials` file contains multiple profiles you can pass the -profile name as a string to the `profile` keyword argument (`nothing` by -default) or select a profile by setting the `AWS_PROFILE` environment variable. - -`aws_config` understands the following [AWS CLI environment -variables](http://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html#cli-environment): -`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, -`AWS_DEFAULT_REGION`, `AWS_PROFILE` and `AWS_CONFIG_FILE`. - - -An configuration dictionary can also be created directly from a key pair -as follows. However, putting access credentials in source code is discouraged. - -```julia -aws = aws_config(creds = AWSCredentials("AKIAXXXXXXXXXXXXXXXX", - "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX")) -``` +global _default_aws_config = Ref{Union{AWSConfig,Nothing}}(nothing) """ -function aws_config(;profile=nothing, - creds=AWSCredentials(profile=profile), - region=get(ENV, "AWS_DEFAULT_REGION", "us-east-1"), - args...) - @SymDict(creds, region, args...) -end - - -global _default_aws_config = nothing # Union{AWSConfig,Nothing} + default_aws_config() - -""" -`default_aws_config` returns a global shared [`AWSConfig`](@ref) object -obtained by calling [`aws_config`](@ref) with no optional arguments. +Return the global shared [`AWSConfig`](@ref) object obtained by calling +[`AWSConfig()`](@ref) with no arguments. """ function default_aws_config() global _default_aws_config - if _default_aws_config === nothing - _default_aws_config = aws_config() + if _default_aws_config[] === nothing + _default_aws_config[] = AWSConfig() end - return _default_aws_config + return _default_aws_config[] end @@ -201,7 +195,7 @@ Service endpoint URL for `request`. """ function service_url(aws, request) endpoint = get(request, :endpoint, request[:service]) - region = "." * aws[:region] + region = "." * aws.region if endpoint == "iam" || (endpoint == "sdb" && region == ".us-east-1") region = "" end @@ -220,7 +214,7 @@ function service_query(aws::AWSConfig; args...) request = Dict{Symbol,Any}(args) request[:verb] = "POST" - request[:resource] = get(aws, :resource, "/") + request[:resource] = get(aws._extras, :resource, "/") request[:url] = service_url(aws, request) request[:headers] = Dict("Content-Type" => "application/x-www-form-urlencoded; charset=utf-8") @@ -230,7 +224,7 @@ function service_query(aws::AWSConfig; args...) request[:query]["Version"] = request[:version] if request[:service] == "iam" - aws = merge(aws, Dict(:region => "us-east-1")) + aws.region = "us-east-1" end if request[:service] in ["iam", "sts", "sqs", "sns"] request[:query]["ContentType"] = "JSON" @@ -238,7 +232,7 @@ function service_query(aws::AWSConfig; args...) request[:content] = HTTP.escapeuri(flatten_query(request[:service], request[:query])) - do_request(merge(request, aws)) + do_request(merge(request, Dict(aws))) end diff --git a/src/AWSCredentials.jl b/src/AWSCredentials.jl index a968c9e..b8dde1f 100644 --- a/src/AWSCredentials.jl +++ b/src/AWSCredentials.jl @@ -17,32 +17,7 @@ export AWSCredentials, aws_account_number -""" -When you interact with AWS, you specify your [AWS Security Credentials](http://docs.aws.amazon.com/general/latest/gr/aws-security-credentials.html) to verify who you are and whether you have permission to access the resources that you are requesting. AWS uses the security credentials to authenticate and authorize your requests. - -The fields `access_key_id` and `secret_key` hold the access keys used to authenticate API requests (see [Creating, Modifying, and Viewing Access Keys](http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html#Using_CreateAccessKey)). - -[Temporary Security Credentials](http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp.html) require the extra session `token` field. - - -The `user_arn` and `account_number` fields are used to cache the result of the [`aws_user_arn`](@ref) and [`aws_account_number`](@ref) functions. - -The `AWSCredentials()` constructor tries to load local Credentials from -environment variables, `~/.aws/credentials`, `~/.aws/config` or EC2 instance credentials. -To specify the profile to use from `~/.aws/credentials`, do, for example, `AWSCredentials(profile="profile-name")`. -""" -mutable struct AWSCredentials - access_key_id::String - secret_key::String - token::String - user_arn::String - account_number::String - - function AWSCredentials(access_key_id, secret_key, - token="", user_arn="", account_number="") - new(access_key_id, secret_key, token, user_arn, account_number) - end -end +# AWSCredentials type defined in src/AWSCore.jl function Base.show(io::IO,c::AWSCredentials) println(io, string(c.user_arn, @@ -145,7 +120,7 @@ e.g. `"arn:aws:iam::account-ID-without-hyphens:user/Bob"` """ function aws_user_arn(aws::AWSConfig) - creds = aws[:creds] + creds = aws.creds if creds.user_arn == "" @@ -164,7 +139,7 @@ end 12-digit [AWS Account Number](http://docs.aws.amazon.com/general/latest/gr/acct-identifiers.html). """ function aws_account_number(aws::AWSConfig) - creds = aws[:creds] + creds = aws.creds if creds.account_number == "" aws_user_arn(aws) end @@ -322,7 +297,7 @@ function aws_get_role(role::AbstractString, ini::Inifile) end credentials = dot_aws_credentials(source_profile) - config = AWSConfig(:creds=>credentials, :region=>aws_get_region(source_profile, ini)) + config = AWSConfig(creds=credentials, region=aws_get_region(source_profile, ini)) role = Services.sts( config, diff --git a/src/names.jl b/src/names.jl index 232baa7..8a42d76 100644 --- a/src/names.jl +++ b/src/names.jl @@ -21,7 +21,7 @@ export arn, is_arn, Generate an [Amazon Resource Name](http://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html) for `service` and `resource`. """ function arn(service, resource, - region=get(default_aws_config(), :region, ""), + region=default_aws_config().region, account=aws_account_number(default_aws_config())) if service == "s3" @@ -38,7 +38,7 @@ end function arn(aws::AWSConfig, service, resource, - region=get(aws, :region, ""), + region=aws.region, account=aws_account_number(aws)) arn(service, resource, region, account) diff --git a/test/runtests.jl b/test/runtests.jl index 0ad98d2..d0cf97c 100755 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -19,7 +19,7 @@ AWSCore.set_debug_level(1) @testset "AWSCore" begin -aws = aws_config() +aws = AWSConfig() @testset "Load Credentials" begin user = aws_user_arn(aws) @@ -28,7 +28,7 @@ aws = aws_config() println("Authenticated as: $user") - aws[:region] = "us-east-1" + aws.region = "us-east-1" println("Testing exceptions...") try @@ -121,32 +121,32 @@ end "AWS_ACCESS_KEY_ID" => nothing ) do - # Check credentials load - config = AWSCore.aws_config() - creds = config[:creds] + # Check credentials load + config = AWSCore.AWSConfig() + creds = config.creds @test creds.access_key_id == "TEST_ACCESS_ID" @test creds.secret_key == "TEST_ACCESS_KEY" # Check credential file takes precedence over config ENV["AWS_DEFAULT_PROFILE"] = "test2" - config = AWSCore.aws_config() - creds = config[:creds] + config = AWSCore.AWSConfig() + creds = config.creds @test creds.access_key_id == "RIGHT_ACCESS_ID2" @test creds.secret_key == "RIGHT_ACCESS_KEY2" # Check credentials take precedence over role ENV["AWS_DEFAULT_PROFILE"] = "test3" - config = AWSCore.aws_config() - creds = config[:creds] + config = AWSCore.AWSConfig() + creds = config.creds @test creds.access_key_id == "RIGHT_ACCESS_ID3" @test creds.secret_key == "RIGHT_ACCESS_KEY3" ENV["AWS_DEFAULT_PROFILE"] = "test4" - config = AWSCore.aws_config() - creds = config[:creds] + config = AWSCore.AWSConfig() + creds = config.creds @test creds.access_key_id == "RIGHT_ACCESS_ID4" @test creds.secret_key == "RIGHT_ACCESS_KEY4" @@ -155,7 +155,7 @@ end ENV["AWS_DEFAULT_PROFILE"] = "test:dev" try - AWSCore.aws_config() + AWSCore.AWSConfig() @test false catch e @test e isa AWSCore.AWSException @@ -167,7 +167,7 @@ end let oldout = stdout r,w = redirect_stdout() try - AWSCore.aws_config() + AWSCore.AWSConfig() @test false catch e @test e isa AWSCore.AWSException