-
Notifications
You must be signed in to change notification settings - Fork 247
Add Kubernetes Service support #4164
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
base: master
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -110,8 +110,11 @@ | |||
| from kubernetes.client import V1SecretKeySelector | ||||
| from kubernetes.client import V1SecretVolumeSource | ||||
| from kubernetes.client import V1SecurityContext | ||||
| from kubernetes.client import V1Service | ||||
| from kubernetes.client import V1ServiceAccount | ||||
| from kubernetes.client import V1ServiceAccountTokenProjection | ||||
| from kubernetes.client import V1ServicePort | ||||
| from kubernetes.client import V1ServiceSpec | ||||
| from kubernetes.client import V1StatefulSet | ||||
| from kubernetes.client import V1StatefulSetSpec | ||||
| from kubernetes.client import V1TCPSocketAction | ||||
|
|
@@ -429,6 +432,12 @@ class NodeSelectorsPreferredConfigDict(TypedDict): | |||
| preferences: Dict[str, NodeSelectorConfig] | ||||
|
|
||||
|
|
||||
| class KubernetesServiceConfigDict(TypedDict, total=False): | ||||
| headless: bool # Shortcut for cluster_ip: None | ||||
| annotations: Dict[str, str] | ||||
| port: int # Optional - defaults to 8888 | ||||
|
|
||||
|
|
||||
| class KubernetesDeploymentConfigDict(LongRunningServiceConfigDict, total=False): | ||||
| bounce_method: str | ||||
| bounce_health_params: Dict[str, Any] | ||||
|
|
@@ -452,6 +461,7 @@ class KubernetesDeploymentConfigDict(LongRunningServiceConfigDict, total=False): | |||
| datastore_credentials: DatastoreCredentialsConfig | ||||
| topology_spread_constraints: List[TopologySpreadConstraintDict] | ||||
| enable_aws_lb_readiness_gate: bool | ||||
| k8s_service: KubernetesServiceConfigDict | ||||
|
|
||||
|
|
||||
| def load_kubernetes_service_config_no_cache( | ||||
|
|
@@ -2749,6 +2759,63 @@ def get_projected_sa_volumes(self) -> List[ProjectedSAVolume]: | |||
| soa_dir=self.soa_dir, | ||||
| ) | ||||
|
|
||||
| def get_kubernetes_service_config(self) -> Optional[KubernetesServiceConfigDict]: | ||||
| """Returns the k8s_service config block, or None if not configured.""" | ||||
| return self.config_dict.get("k8s_service") | ||||
|
|
||||
| def get_kubernetes_service_name(self) -> str: | ||||
| """Returns the name for the Kubernetes Service. | ||||
|
|
||||
| Currently matches the deployment name, but can be extended later | ||||
| to support custom service naming if needed. | ||||
| """ | ||||
| return self.get_sanitised_deployment_name() | ||||
|
|
||||
| def format_kubernetes_service(self) -> Optional[V1Service]: | ||||
| """Create a Kubernetes Service configuration from this config. | ||||
|
|
||||
| Returns None if k8s_service is not configured. | ||||
| """ | ||||
| k8s_service_config = self.get_kubernetes_service_config() | ||||
| if k8s_service_config is None: | ||||
| return None | ||||
|
|
||||
| port = k8s_service_config.get("port", 8888) # Default to the PaaSTA port | ||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: could probably default to
|
||||
| annotations = k8s_service_config.get("annotations", {}) | ||||
| annotations[paasta_prefixed("managed")] = "true" | ||||
| headless = k8s_service_config.get("headless", True) | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is there ever a situation where we wouldn't want this to be true? I don't think we'd ever want a client to be connecting to a paasta service/instance using a k8s cluster ip - we'd only ever want to be using a loadbalancer/cloudmap/something else. I'd probably just remove this config option
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point, I agree, I think we can remove this option for now. Thanks! |
||||
|
|
||||
| service_port = V1ServicePort( | ||||
| name="http", port=port, target_port=port, protocol="TCP" | ||||
| ) | ||||
|
|
||||
| service_spec = V1ServiceSpec( | ||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. super premature but this coudl be a great stepping stone to support service NLBs in paasta. According to https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/guide/service/nlb/ the only additions we would need are:
there is a third requirement which is to set an annotation of an alternative would otherwise to have a top level yelpsoa-config directive
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I really like this idea, and it sounds like a small change to support it. I will wait to see what other reviewers think before making any changes. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes, this is really great idea! |
||||
| ports=[service_port], | ||||
| selector={ | ||||
| paasta_prefixed("service"): self.get_service(), | ||||
| paasta_prefixed("instance"): self.get_instance(), | ||||
| }, | ||||
| ) | ||||
| if headless: | ||||
| service_spec.cluster_ip = "None" | ||||
|
|
||||
| service = V1Service( | ||||
| api_version="v1", | ||||
| kind="Service", | ||||
| metadata=V1ObjectMeta( | ||||
| name=self.get_kubernetes_service_name(), | ||||
| namespace=self.get_namespace(), | ||||
| labels={ | ||||
| paasta_prefixed("service"): self.get_service(), | ||||
| paasta_prefixed("instance"): self.get_instance(), | ||||
| paasta_prefixed("managed"): "true", | ||||
| }, | ||||
| annotations=annotations, | ||||
| ), | ||||
| spec=service_spec, | ||||
| ) | ||||
| return service | ||||
|
|
||||
|
|
||||
| def get_kubernetes_secret_hashes( | ||||
| environment_variables: Mapping[str, str], service: str, namespace: str | ||||
|
|
@@ -3866,6 +3933,40 @@ def create_job( | |||
| ) | ||||
|
|
||||
|
|
||||
| def create_service( | ||||
| kube_client: KubeClient, | ||||
| formatted_service: V1Service, | ||||
| namespace: str, | ||||
| ) -> None: | ||||
| return kube_client.core.create_namespaced_service( | ||||
| namespace=namespace, | ||||
| body=formatted_service, | ||||
| ) | ||||
|
|
||||
|
|
||||
| def update_service( | ||||
| kube_client: KubeClient, | ||||
| formatted_service: V1Service, | ||||
| namespace: str, | ||||
| ) -> None: | ||||
| return kube_client.core.replace_namespaced_service( | ||||
| name=formatted_service.metadata.name, | ||||
| namespace=namespace, | ||||
| body=formatted_service, | ||||
| ) | ||||
|
|
||||
|
|
||||
| def delete_service( | ||||
| kube_client: KubeClient, | ||||
| service_name: str, | ||||
| namespace: str, | ||||
| ) -> None: | ||||
| return kube_client.core.delete_namespaced_service( | ||||
| name=service_name, | ||||
| namespace=namespace, | ||||
| ) | ||||
|
|
||||
|
|
||||
| def get_event_timestamp(event: CoreV1Event) -> Optional[float]: | ||||
| # Cycle through timestamp attributes in order of preference | ||||
| for ts_attr in ["last_timestamp", "event_time", "first_timestamp"]: | ||||
|
|
||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -330,3 +330,67 @@ def test_job_wrapper_deep_delete(): | |
| "mock_namespace", | ||
| body=V1DeleteOptions(propagation_policy="Foreground"), | ||
| ) | ||
|
|
||
|
|
||
| def test_sync_service_creates_service_when_configured(): | ||
| """Test sync_service creates a Service when k8s_service is configured.""" | ||
| mock_client = mock.MagicMock() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i was gonna say that we should be using a mock that's autospec'd on the kube client...but it seems like this is a pre-existing pattern already in this file so nevermind :p |
||
| config_dict = { | ||
| "k8s_service": { | ||
| "headless": True, | ||
| "port": 8888, | ||
| "annotations": { | ||
| "external-dns.alpha.kubernetes.io/hostname": "test1.example.com" | ||
| }, | ||
| } | ||
| } | ||
| app = setup_app(config_dict, True) | ||
| app.exists_service = mock.Mock(return_value=False) | ||
| app.sync_service(kube_client=mock_client) | ||
| mock_client.core.create_namespaced_service.assert_called_once() | ||
| assert mock_client.core.replace_namespaced_service.call_count == 0 | ||
| assert mock_client.core.delete_namespaced_service.call_count == 0 | ||
|
|
||
|
|
||
| def test_sync_service_updates_existing_service(): | ||
| """Test sync_service updates a Service when it already exists.""" | ||
| mock_client = mock.MagicMock() | ||
| config_dict = { | ||
| "k8s_service": { | ||
| "headless": True, | ||
| "port": 8889, | ||
| "annotations": { | ||
| "external-dns.alpha.kubernetes.io/hostname": "test2.example.com" | ||
| }, | ||
| } | ||
| } | ||
| app = setup_app(config_dict, True) | ||
| app.exists_service = mock.Mock(return_value=True) | ||
| app.sync_service(kube_client=mock_client) | ||
| mock_client.core.replace_namespaced_service.assert_called_once() | ||
| assert mock_client.core.create_namespaced_service.call_count == 0 | ||
| assert mock_client.core.delete_namespaced_service.call_count == 0 | ||
|
|
||
|
|
||
| def test_sync_service_deletes_when_config_removed(): | ||
| """Test sync_service deletes Service when k8s_service config is removed.""" | ||
| mock_client = mock.MagicMock() | ||
| config_dict = {} | ||
| app = setup_app(config_dict, True) | ||
| app.exists_service = mock.Mock(return_value=True) | ||
| app.sync_service(kube_client=mock_client) | ||
| mock_client.core.delete_namespaced_service.assert_called_once() | ||
| assert mock_client.core.create_namespaced_service.call_count == 0 | ||
| assert mock_client.core.replace_namespaced_service.call_count == 0 | ||
|
|
||
|
|
||
| def test_sync_service_noop_when_not_configured_and_doesnt_exist(): | ||
| """Test sync_service does nothing when not configured and doesn't exist.""" | ||
| mock_client = mock.MagicMock() | ||
| config_dict = {} | ||
| app = setup_app(config_dict, True) | ||
| app.exists_service = mock.Mock(return_value=False) | ||
| app.sync_service(kube_client=mock_client) | ||
| assert mock_client.core.create_namespaced_service.call_count == 0 | ||
| assert mock_client.core.replace_namespaced_service.call_count == 0 | ||
| assert mock_client.core.delete_namespaced_service.call_count == 0 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
note: we'll also need to add
k8s_serviceto https://github.com/Yelp/paasta/blob/cb5eca340f12e3d63807f4ce35bcccad0711abe2/paasta_tools/cli/schemas/eks_schema.json