Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Operator v1: Support multiple external listeners in Cluster CRD #455

Open
wants to merge 9 commits into
base: main
Choose a base branch
from

Conversation

paulzhang97
Copy link
Contributor

We support only one external listener in each of the API endpoints (Kafka, Proxy, and Schema Registry) in Cluster CRD. This PR is to support multiple external listeners in Cluster CRD.

@paulzhang97 paulzhang97 changed the title [DRAFT] Support multiple external listeners in Cluster CRD [DRAFT] Operator v1: Support multiple external listeners in Cluster CRD Feb 27, 2025
@paulzhang97 paulzhang97 force-pushed the paulz/multi-external-listeners branch from f86e73d to d1ecee8 Compare March 3, 2025 03:07
@paulzhang97 paulzhang97 changed the title [DRAFT] Operator v1: Support multiple external listeners in Cluster CRD Operator v1: Support multiple external listeners in Cluster CRD Mar 3, 2025
AuthenticationMethod string
}

// Encode returns the listenerTemplateSpec as a string in the format as below:
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a reason to not use json/yaml.Marshal here? If the need is to avoid quoting of potentially templated values, you should be able to work around that with a custom type and omitempty:

type TemplatedString string

type TemplatedInt string

func (t TemplatedString) MarshalYAML() {
// format the value so it's not escaped here. It should be possible to generate invalid JSON/YAML though I've not tried it myself...
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You make me think it again 😄 . The issue here is how to wrap key and value with single quote '. I still could not figure out how to do it.
We need to encode it like { 'name': 'listener-name', 'address':'0.0.0.0'} rather than { "name": "listener-name", "address": "0.0.0.0" }.

Copy link
Contributor

@chrisseto chrisseto Mar 3, 2025

Choose a reason for hiding this comment

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

Why do we need to encode them with single quotes?

EDIT:

If that's the constraint, feel we'd be better off regexing the quotes after JSON marshaling than maintaining all this custom serialization code. Something like: s/(^|[^\\])"/'/g should do it. You just need to be sure to not replace escaped quotes in strings which is what the capture group there should do. Though a negative lookbehind should work as well if you're familiar with them and go's regex engine supports them. I'd like to better understand they why of single quotes before going down that path though ;)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I haven't looked into why single quotes are needed. I feel that like it has something to do with config.Set().

I like your proposal. I will give it a try.

Copy link
Contributor Author

@paulzhang97 paulzhang97 Mar 4, 2025

Choose a reason for hiding this comment

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

Well. I made hybrid changes. See the last commit.

I failed to write MarshalJSON for allListenersTemplateSpec, since a list type of field inside the struct needs to be encoded as e.g. "redpanda.kafka_api":"[{...},{...}]" (with double quotes on the list value, the value needs to be a string) instead of redpanda.kafka_api":[{...},{...}]. I tried to pass the one without double quotes to Configurator, it does not work. Did not dig into much. Bottom of line, I would not want to change how it works today since it is how Cloudv2 configures additional listeners for Private Link today.

Any other idea?

Copy link
Contributor

Choose a reason for hiding this comment

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

config.Set just calls yaml.Unmarshal (With some... interesting preprocessing). I see no reason why single quotes would be required.

// Set sets a field in pointer-to-struct p to a value, following yaml tags.
//
//	Key:    string containing the yaml field tags, e.g: 'rpk.admin_api'.
//	Value:  string representation of the value
func Set[T any](p *T, key, value string) error {
	if key == "" {
		return fmt.Errorf("key field must not be empty")
	}
	tags := strings.Split(key, ".")
	for _, tag := range tags {
		if _, _, err := splitTagIndex(tag); err != nil {
			return err
		}
	}
	finalTag := tags[len(tags)-1]
	if len(tags) > 1 && (finalTag == "enabled" && tags[len(tags)-2] == "tls" || finalTag == "tls") {
		switch value {
		case "{}":
		case "null":
		case "true":
			value = "{}"
		case "false":
			value = "null"
		default:
			// If the final tag is 'tls', it might be a value. So we continue
			// and handle below.
			if finalTag != "tls" {
				return fmt.Errorf("%s must be true or {}", key)
			}
		}
		if finalTag == "enabled" {
			tags = tags[:len(tags)-1]
			finalTag = tags[len(tags)-1]
		}
	}

	field, other, err := getField(tags, "", reflect.ValueOf(p).Elem())
	if err != nil {
		return err
	}
	isOther := other != reflect.Value{}

	// For Other fields, we need to wrap the value in key:value format when
	// unmarshaling, and we forbid indexing.
	if isOther {
		if _, index, _ := splitTagIndex(finalTag); index >= 0 {
			return fmt.Errorf("cannot index into unknown field %q", finalTag)
		}
		field = other
	}

	if !field.CanAddr() {
		return errors.New("rpk bug, please describe how you encountered this at https://github.com/redpanda-data/redpanda/issues/new?assignees=&labels=kind%2Fbug&template=01_bug_report.md")
	}

	if isOther {
		value = fmt.Sprintf("%s: %s", finalTag, value)
	}

	// If we cannot unmarshal, but our error looks like we are trying to
	// unmarshal a single element into a slice, we index[0] into the slice
	// and try unmarshaling again.
	rawv := []byte(value)
	if err := yaml.Unmarshal(rawv, field.Addr().Interface()); err != nil {
		// First we try wrapped with [ and ].
		if wrapped, ok := tryValueAsUnwrappedArray(field, value, err); ok {
			if err := yaml.Unmarshal([]byte(wrapped), field.Addr().Interface()); err == nil {
				return nil
			}
		}
		// If that still fails, we try setting a slice value if the
		// target is a slice.
		if elem0, ok := tryValueAsSlice0(field, err); ok {
			return yaml.Unmarshal(rawv, elem0.Addr().Interface())
		}
		return err
	}
	return nil
}

It also seems like it's be reasonably easy to side step config.Set in favor of directly setting these values in the configurator. Something like:

res, err := utils.Compute(v, utils.NewEndpointTemplateData(hostIndex, hostIP, hostIndexOffset), false)
if err != nil {
	return err
}

var addlListeners []config.NamedAuthNSocketAddress
yaml.Unmarshal(*&res, addListeners)

nodeConfig.Redpanda.KafkaAPI = append(nodeConfig.Redpanda.KafkaAPI,  addlListeners)

It also appears that config.Set has been removed (or at least I can't find it) in newer versions of rpk 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I see config.set is still there.

I will take a look at replacing config.Set, and see whether it is viable since we would not want to change the way how the env ADDITIONAL_LISTENERS is set.

FYI, if using double quotes instead of single quotes, I get the error such as invalid character 'n' after object key:value pair.

Copy link
Contributor Author

@paulzhang97 paulzhang97 Mar 4, 2025

Choose a reason for hiding this comment

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

Update: it works if I use escaped double quotes as the outer string uses double quotes, like, "[{\"name\": \"mtls-kafka\", 'address': '{{ .Index }}-f415bda0-{{ .HostIP | sha256sum | substr 0 7 }}.redpanda.com', 'port': {{39002 | add .Index}}}]"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think I did it. Please check.

SchemaRegistryTLSSpec []tlsTemplateSpec
}

// Encode returns the allListenersTemplateSpec as a string in the format as below:
Copy link
Contributor

Choose a reason for hiding this comment

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

This entire file would dramatically benefit from some unit testing. It may also be useful to execute the template and unmarshal the resultant value to ensure everything's being formatted correctly.

If the previously mentioned custom types work, you could narrow down the testing scope to just those types.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The new unit tests added for statefulset exercise the functions. It is confusing that it seems no test coverage for this file. I will try to do some refactoring or create extra tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added new unit tests.

httpBasic := "http_basic"
mtls := "mtls_identity"

require.NoError(t, vectorizedv1alpha1.AddToScheme(scheme.Scheme))
Copy link
Contributor

Choose a reason for hiding this comment

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

Don't mutate global values. There's a package that exports a Scheme with both v1 and v2 CRs added that you can reference instead of building a new scheme here.

Copy link
Contributor Author

@paulzhang97 paulzhang97 Mar 3, 2025

Choose a reason for hiding this comment

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

I have not found the pkg so far. Could you send a link to it?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think I found it. Is it this?

Copy link
Contributor

Choose a reason for hiding this comment

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

That's the one!

}

// validateAuthNListeners checks whether listeners1 is equal to listeners2.
func validateAuthNListeners(t *testing.T, cfg1, cfg2 []config.NamedAuthNSocketAddress) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Does require.ElementsMatch not work here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It works. TIL.

// vaule1 = [{'name':'mtls-kafka','port':{{9094 | add .Index | add .HostIndexOffset}}}]
// value2 = [{'name':'sasl-kafka','port': {{9092 | add .Index | add .HostIndexOffset}}}]
// Concat value = [{'name':'mtls-kafka','port':{{9094 | add .Index | add .HostIndexOffset}},{'name':'pl2-kafka','port': {{9092 | add .Index | add .HostIndexOffset}}}]
func (a *allListenersTemplateSpec) Concat(spec1 map[string]string) (string, error) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Again citing the above comment, you should be able to ditch all of this if you have a type that can correctly serialize templated values.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I know. I will think hard to see whether we can leverage customize MarshalJSON functions.

@@ -950,6 +947,7 @@ func (r *StatefulSetResource) portsConfiguration() string {
return fmt.Sprintf("--advertise-rpc-addr=$(POD_NAME).%s:%d", serviceFQDN, rpcAPIPort)
}

// TODO
Copy link
Contributor

Choose a reason for hiding this comment

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

TODO what?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I will remove.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.

@paulzhang97 paulzhang97 requested a review from chrisseto March 3, 2025 22:20
@paulzhang97 paulzhang97 force-pushed the paulz/multi-external-listeners branch from 339ee8d to e2124f7 Compare March 5, 2025 03:40
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.

2 participants