Skip to content

Conversation

ArieGato
Copy link

Rework the configuration, so the configuration is seperated from cli program.

Add missing configurable properties

ini file:
Add http_port as option to the listen section of the ini file.

enviroment variables:
UPSTREAM for consistent naming. AMPQ_URL takes precendence over UPSTREAM for backwards compatibility cli

Cli options:
--log-level for the Log Level

resolves #204

@ArieGato ArieGato requested a review from a team as a code owner March 29, 2025 10:02
@ArieGato
Copy link
Author

Are there any ci/test github actions that should be running?

@dentarg
Copy link
Member

dentarg commented Mar 30, 2025

Yes but the workflow is missing the pull request event.

name: CI
on:
push:
paths:
- 'run-specs-in-docker.sh'
- '.github/workflows/ci.yml'
- 'shard.yml'
- 'shard.lock'
- 'src/**'
- 'spec/**'
- 'test/**'

It should be corrected in main branch, and after that you can rebase, and CI will run.

@ArieGato
Copy link
Author

so, add this?

  pull_request:
    branches:
      - main

Copy link
Member

@snichme snichme left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How you handle Config and Options structs doesn't feel idiomatic crystal. You recreate the structs every time you call with instead of having it as one Class where you just update the fields on that object.

Having one object that you initialize in the CLI, keeping the reference through out the applications makes it easier to follow and any object that has a reference to that object will be able to read any changes to the config.

The content of the options.cr file looks like you repeat the same variables three times, this might be the way in C#. But in crystal you create one object and reuse that.

Breaking out the code form CLI.cr is nice, but keep it as one file, config and options can be merged.

@ArieGato
Copy link
Author

ArieGato commented Apr 2, 2025

How you handle Config and Options structs doesn't feel idiomatic crystal. You recreate the structs every time you call with instead of having it as one Class where you just update the fields on that object.
Having one object that you initialize in the CLI, keeping the reference through out the applications makes it easier to follow and any object that has a reference to that object will be able to read any changes to the config.

The general idea was to have an immutable object. Which can only be initialized through the load_with_cli method. Also, I think that config should not be altered after initialization.

I see what you mean about the remark about the Option.cr. Although I doubt Config and Option can be merged.

@ArieGato
Copy link
Author

ArieGato commented Apr 5, 2025

@snichme I did some rework. Could you take a look at the pr?

Also there are workflows failing, but I don't think it has anything to do with my changes.

@snichme
Copy link
Member

snichme commented Apr 7, 2025

The general idea was to have an immutable object. Which can only be initialized through the load_with_cli method. Also, I think that config should not be altered after initialization.

Any reason why it shouldn't be altered later on? If you have one config object and during runtime change that I think it's good that the change applies to all other objects that have a reference to the config object.

@snichme
Copy link
Member

snichme commented Apr 7, 2025

If you instead do it like this in cli.cr:

config = @config = AMQProxy::Config.new
  .load_from_file(options.ini_file || "config.ini")
  .load_from_env

and after than parse the options and set them directly on the config object there is no need to first create then options object and set all the options and them copy them over to the config object in the load_from_options method. It will also reduce the places one need to change if more configs are added.

Like so:

config = @config = AMQProxy::Config.new
  .load_from_file(options.ini_file || "config.ini")
  .load_from_env

parser.on("-p PORT", "--port=PORT", "Port to listen on (default: 5673)") { |v| config.listen_port = v.to_i }

wdyt?

@ArieGato
Copy link
Author

ArieGato commented Apr 7, 2025

From an OO perspective I believe that setting properties should be done from within the object. Other object should not need to know the correct precendence of loading the configuration. At the moment there are no validations on the configuration, but that should also be the responsibility of the config class. If combination of configuration properties are not valid, config can handle this.

If configuration is allowed to change in the lifecyle of the application, a method can be added for a specific action that results in configuration changes. e.g. a reload method on config that accepts new options.

I also considered adding the args as parameter to the config load_with_cli and parse the options there, but that didn't feel right.

@snichme
Copy link
Member

snichme commented Apr 7, 2025

From an OO perspective I believe that setting properties should be done from within the object. Other object should not need to know the correct precendence of loading the configuration. At the moment there are no validations on the configuration, but that should also be the responsibility of the config class. If combination of configuration properties are not valid, config can handle this.

This can still happen if you create a method like so: def listen_port=(value : UInt32) can you can validate the input and check if it's valid in combination with the current state.

@ArieGato
Copy link
Author

ArieGato commented Apr 7, 2025

@carlhoerberg The Build packages and Docker images workflows are failing for my PR. Is there something I'm msising here?

@snichme
Copy link
Member

snichme commented Apr 7, 2025

Here is another interesting idea on how to handle config values by using crystal macros, so you have one property and you can tag it with different annotations depending on if it's from file, env or as arg: cloudamqp/lavinmq#917

@carlhoerberg
Copy link
Member

@carlhoerberg The Build packages and Docker images workflows are failing for my PR. Is there something I'm msising here?

That's expected and can be ignored

@ArieGato
Copy link
Author

ArieGato commented Apr 7, 2025

Here is another interesting idea on how to handle config values by using crystal macros, so you have one property and you can tag it with different annotations depending on if it's from file, env or as arg: cloudamqp/lavinmq#917

In that PR the cli arguments are parsed in the config class. If that is okay, then it can also solve the issue of the Options class.

I think there's one issue with the solution in that PR. In the ini file in amqproxy there are two settings that handle the same property. Or is it possible to add multiple attributes of the same type to a property?

when "bind", "address" then @listen_address = value

@ArieGato
Copy link
Author

@snichme I moved the OptionParser to the config class and thus removing the extra Options record. Can you take another look at the PR?

Comment on lines +101 to +109
def self.load_with_cli(argv)
new()
.load_cli_options(argv.dup) # handle config file/help/version options
.load_from_file
.load_from_env
.load_cli_options(argv)
rescue ex
abort ex.message
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def self.load_with_cli(argv)
new()
.load_cli_options(argv.dup) # handle config file/help/version options
.load_from_file
.load_from_env
.load_cli_options(argv)
rescue ex
abort ex.message
end
def initialize(argv)
load_cli_options(argv.dup)
load_from_file
load_from_env
load_cli_options(argv)
end

This feels more crystal style IMO, also move to top since it's the constructor

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to do a solution where we didn't need to parse the ARGV twice?
Like the first load_cli_options returns something that we can later apply.

If we return a NamedTuple from load_cli_options we can also make use of that and pass the path to the config file to load_from_file like load_from_file(opts[:config_file]) to make it clearer.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When returning a NamedTuple we're sort of down to the Option record/class again. Which is fine by me, but is it a bad thing to process the cli options twice? Something that happens once at startup and seems cpu friendly.

else raise "Unsupported config #{name}/#{key}"
end
end
else raise "Unsupported config section #{name}"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this exists today, but config options that isn't support shouldn't we just skip them?
How does it work for unsupported cli args, does that raise?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that this seems a little harsh.

end

unless config = @config
raise "Configuration has not been loaded"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means that for some reason @config isn't set we dont disconnect clients, we just stop the process. Isn't @config always set here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes It is always set, but here my Crystal knowledge falls short. I just don't know how I can convince the compiler it is. When I use not_nil I get the suggestion not to use this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to make sure that it is set by being strict that it has a value, i.e. not letting it be nilable :)

@idle_connection_timeout : Int32 = 5
@term_timeout = -1
@term_client_close_timeout = 0
@config : AMQProxy::Config? = nil
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible to make this not optional?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can I do that? creating the instance happens in the run method.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of a run() method, you can use an initializer and let cli.new() set @config when initializing, that way I think you can avoid letting it be Nilable.

@ArieGato
Copy link
Author

@snichme Any thoughts on my replies?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to avoid having empty files in the repository, maybe this file can be created when needed in config_spec.cr and then cleaned up at the end of the spec.

Copy link
Member

@dentarg dentarg Jun 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it good to avoid empty files in the repo? If the file serves a purpose (used in specs? I haven't actually checked this 🙈), we should not be afraid to have it around. Creating it with code sounds both more complex and slower to me.

Copy link
Member

@kickster97 kickster97 Jun 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My reasoning is mostly about cleanliness so the repository remains free of files that are solely for transient test purposes.
Sure complexion grows slightly, thats a good point. I don't know the performance impact of creating an empty file, but is it really notably expensive to create once?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we have files like this in the repo I think it should be clear in some way what they are used for so its less confusing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree it should be more made more clear. Could be made by having a "fixtures" or "config fixtures" directory.

@idle_connection_timeout : Int32 = 5
@term_timeout = -1
@term_client_close_timeout = 0
@config : AMQProxy::Config? = nil
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of a run() method, you can use an initializer and let cli.new() set @config when initializing, that way I think you can avoid letting it be Nilable.

end

unless config = @config
raise "Configuration has not been loaded"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to make sure that it is set by being strict that it has a value, i.e. not letting it be nilable :)

Comment on lines +6 to +13
previous_argv = ARGV.clone
ARGV.clear

ARGV.concat([
"--config=/tmp/non_existing_file.ini",
])

config = AMQProxy::Config.load_with_cli(ARGV)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need to do all the ARGV handling in each spec, you can just pass an array of strings (or an empty array where needed) to load_with_cli instead.

Suggested change
previous_argv = ARGV.clone
ARGV.clear
ARGV.concat([
"--config=/tmp/non_existing_file.ini",
])
config = AMQProxy::Config.load_with_cli(ARGV)
config = AMQProxy::Config.load_with_cli(["--config=/tmp/non_existing_file.ini"])

@@ -1,3 +1,4 @@
require "./config"
require "./version"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

require "./version" should probably be moved to config.cr since that's where AMQProxy::VERSION is used?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this?

Comment on lines +14 to +15
COPY spec/config.ini /tmp/config.ini
COPY spec/config_empty.ini /tmp/config_empty.ini
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All files in spec/ is already copied on the line above?

@ArieGato
Copy link
Author

Hi @carlhoerberg ,

To be honest, I'm a little hesitent to finish this PR.

I don't agree with several of the comments I got on this PR. Although my background is c#, some of the principles are not bound to a language. Things like immutability can result into more predictable code.

For instance the concept of a class to store the cli arguments in an Options class which is immutable is a solid idea. My initial implementation was not very in line with the syntax of Crystal. Although several of the properties match the ones of the eventual Config object shouldn't be an argument not to add it.

Another thing is exposing the methods for reading config sections cli/env/file/defaults is imo not the responsibility of the code that is invoking the methods. Just call Configure or some method name should suffice. It is the responsibility of the Config object to know the sequence.

Let me know what's your take on it.

Regards,
Arjan

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add consistent configuration
6 participants