Skip to content

Commit 22e409c

Browse files
authored
Merge branch 'main' into upgrade_azurerm_provider
2 parents f27ff93 + 621ea23 commit 22e409c

File tree

5 files changed

+149
-81
lines changed

5 files changed

+149
-81
lines changed

.github/workflows/test_local_integration.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ jobs:
7373
python-version: "3.11"
7474
miniconda-version: "latest"
7575

76+
- name: Install JQ
77+
run: |
78+
sudo apt-get update
79+
sudo apt-get install jq -y
80+
7681
- name: Install Nebari and playwright
7782
run: |
7883
pip install .[dev]
@@ -97,6 +102,14 @@ jobs:
97102
nebari keycloak adduser --user "${TEST_USERNAME}" "${TEST_PASSWORD}" --config ${{ steps.init.outputs.config }}
98103
nebari keycloak listusers --config ${{ steps.init.outputs.config }}
99104
105+
- name: Await Workloads
106+
uses: jupyterhub/action-k8s-await-workloads@v3
107+
with:
108+
workloads: "" # all
109+
namespace: "dev"
110+
timeout: 60
111+
max-restarts: 0
112+
100113
### DEPLOYMENT TESTS
101114
- name: Deployment Pytests
102115
env:

src/_nebari/stages/infrastructure/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import tempfile
99
from typing import Annotated, Any, Dict, List, Literal, Optional, Tuple, Type, Union
1010

11-
from pydantic import ConfigDict, Field, field_validator, model_validator
11+
from pydantic import ConfigDict, Field, PrivateAttr, field_validator, model_validator
1212

1313
from _nebari import constants
1414
from _nebari.provider import terraform
@@ -136,7 +136,7 @@ class AWSAmiTypes(str, enum.Enum):
136136

137137
class AWSNodeLaunchTemplate(schema.Base):
138138
pre_bootstrap_command: Optional[str] = None
139-
ami_id: Optional[str] = None
139+
_ami_id: Optional[str] = PrivateAttr(default=None)
140140

141141

142142
class AWSNodeGroupInputVars(schema.Base):
@@ -155,7 +155,7 @@ class AWSNodeGroupInputVars(schema.Base):
155155
def construct_aws_ami_type(gpu_enabled: bool, launch_template: AWSNodeLaunchTemplate):
156156
"""Construct the AWS AMI type based on the provided parameters."""
157157

158-
if launch_template and launch_template.ami_id:
158+
if launch_template and launch_template._ami_id:
159159
return "CUSTOM"
160160

161161
if gpu_enabled:

src/_nebari/stages/infrastructure/template/aws/modules/network/main.tf

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,15 @@ resource "aws_security_group" "main" {
5555
vpc_id = aws_vpc.main.id
5656

5757
ingress {
58+
description = "Allow all ports and protocols to enter the security group"
5859
from_port = 0
5960
to_port = 0
6061
protocol = "-1"
6162
cidr_blocks = [var.vpc_cidr_block]
6263
}
6364

6465
egress {
66+
description = "Allow all ports and protocols to exit the security group"
6567
from_port = 0
6668
to_port = 0
6769
protocol = "-1"

src/_nebari/upgrade.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import json
88
import logging
9+
import os
910
import re
1011
import secrets
1112
import string
@@ -1297,11 +1298,38 @@ def _version_specific_upgrade(
12971298

12981299
urllib3.disable_warnings()
12991300

1300-
keycloak_admin = get_keycloak_admin(
1301-
server_url=f"https://{config['domain']}/auth/",
1302-
username="root",
1303-
password=config["security"]["keycloak"]["initial_root_password"],
1301+
keycloak_username = os.environ.get("KEYCLOAK_ADMIN_USERNAME", "root")
1302+
keycloak_password = os.environ.get(
1303+
"KEYCLOAK_ADMIN_PASSWORD",
1304+
config["security"]["keycloak"]["initial_root_password"],
13041305
)
1306+
1307+
try:
1308+
# Quick test to connect to Keycloak
1309+
keycloak_admin = get_keycloak_admin(
1310+
server_url=f"https://{config['domain']}/auth/",
1311+
username=keycloak_username,
1312+
password=keycloak_password,
1313+
)
1314+
except ValueError as e:
1315+
if "invalid_grant" in str(e):
1316+
rich.print(
1317+
textwrap.dedent(
1318+
"""
1319+
[red bold]Failed to connect to the Keycloak server.[/red bold]\n
1320+
[yellow]Please set the [bold]KEYCLOAK_ADMIN_USERNAME[/bold] and [bold]KEYCLOAK_ADMIN_PASSWORD[/bold]
1321+
environment variables with the Keycloak root credentials and try again.[/yellow]
1322+
"""
1323+
)
1324+
)
1325+
exit()
1326+
else:
1327+
# Handle other exceptions
1328+
rich.print(
1329+
f"[red bold]An unexpected error occurred: {repr(e)}[/red bold]"
1330+
)
1331+
exit()
1332+
13051333
# Get client ID as role is bound to the JupyterHub client
13061334
client_id = keycloak_admin.get_client_id("jupyterhub")
13071335
role_name = "legacy-group-directory-creation-role"
Lines changed: 99 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import re
22
import string
3+
import time
34
import uuid
45

56
import paramiko
@@ -14,64 +15,80 @@
1415
TIMEOUT_SECS = 300
1516

1617

17-
@pytest.fixture(scope="function")
18+
@pytest.fixture(scope="session")
1819
def paramiko_object(jupyterhub_access_token):
19-
"""Connects to JupyterHub ssh cluster from outside the cluster."""
20+
"""Connects to JupyterHub SSH cluster from outside the cluster.
21+
22+
Ensures the JupyterLab pod is ready before attempting reauthentication
23+
by setting both `auth_timeout` and `banner_timeout` appropriately,
24+
and by retrying the connection until the pod is ready or a timeout occurs.
25+
"""
2026
params = {
2127
"hostname": constants.NEBARI_HOSTNAME,
2228
"port": 8022,
2329
"username": constants.KEYCLOAK_USERNAME,
2430
"password": jupyterhub_access_token,
2531
"allow_agent": constants.PARAMIKO_SSH_ALLOW_AGENT,
2632
"look_for_keys": constants.PARAMIKO_SSH_LOOK_FOR_KEYS,
27-
"auth_timeout": 5 * 60,
2833
}
2934

3035
ssh_client = paramiko.SSHClient()
3136
ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
32-
try:
33-
ssh_client.connect(**params)
34-
yield ssh_client
35-
finally:
36-
ssh_client.close()
37-
38-
39-
def run_command(command, stdin, stdout, stderr):
40-
delimiter = uuid.uuid4().hex
41-
stdin.write(f"echo {delimiter}start; {command}; echo {delimiter}end\n")
42-
43-
output = []
44-
45-
line = stdout.readline()
46-
while not re.match(f"^{delimiter}start$", line.strip()):
47-
line = stdout.readline()
4837

49-
line = stdout.readline()
50-
if delimiter not in line:
51-
output.append(line)
52-
53-
while not re.match(f"^{delimiter}end$", line.strip()):
54-
line = stdout.readline()
55-
if delimiter not in line:
56-
output.append(line)
57-
58-
return "".join(output).strip()
59-
60-
61-
@pytest.mark.timeout(TIMEOUT_SECS)
62-
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
63-
@pytest.mark.filterwarnings("ignore::ResourceWarning")
64-
def test_simple_jupyterhub_ssh(paramiko_object):
65-
stdin, stdout, stderr = paramiko_object.exec_command("")
38+
yield ssh_client, params
39+
40+
ssh_client.close()
41+
42+
43+
def invoke_shell(
44+
client: paramiko.SSHClient, params: dict[str, any]
45+
) -> paramiko.Channel:
46+
client.connect(**params)
47+
return client.invoke_shell()
48+
49+
50+
def extract_output(delimiter: str, output: str) -> str:
51+
# Extract the command output between the start and end delimiters
52+
match = re.search(rf"{delimiter}start\n(.*)\n{delimiter}end", output, re.DOTALL)
53+
if match:
54+
print(match.group(1).strip())
55+
return match.group(1).strip()
56+
else:
57+
return output.strip()
58+
59+
60+
def run_command_list(
61+
commands: list[str], channel: paramiko.Channel, wait_time: int = 0
62+
) -> dict[str, str]:
63+
command_delimiters = {}
64+
for command in commands:
65+
delimiter = uuid.uuid4().hex
66+
command_delimiters[command] = delimiter
67+
b = channel.send(f"echo {delimiter}start; {command}; echo {delimiter}end\n")
68+
if b == 0:
69+
print(f"Command '{command}' failed to send")
70+
# Wait for the output to be ready before reading
71+
time.sleep(wait_time)
72+
while not channel.recv_ready():
73+
time.sleep(1)
74+
print("Waiting for output")
75+
output = ""
76+
while channel.recv_ready():
77+
output += channel.recv(65535).decode("utf-8")
78+
outputs = {}
79+
for command, delimiter in command_delimiters.items():
80+
command_output = extract_output(delimiter, output)
81+
outputs[command] = command_output
82+
return outputs
6683

6784

6885
@pytest.mark.timeout(TIMEOUT_SECS)
6986
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
7087
@pytest.mark.filterwarnings("ignore::ResourceWarning")
7188
def test_print_jupyterhub_ssh(paramiko_object):
72-
stdin, stdout, stderr = paramiko_object.exec_command("")
73-
74-
# commands to run and just print the output
89+
client, params = paramiko_object
90+
channel = invoke_shell(client, params)
91+
# Commands to run and just print the output
7592
commands_print = [
7693
"id",
7794
"env",
@@ -80,52 +97,60 @@ def test_print_jupyterhub_ssh(paramiko_object):
8097
"ls -la",
8198
"umask",
8299
]
83-
84-
for command in commands_print:
85-
print(f'COMMAND: "{command}"')
86-
print(run_command(command, stdin, stdout, stderr))
100+
outputs = run_command_list(commands_print, channel)
101+
for command, output in outputs.items():
102+
print(f"COMMAND: {command}")
103+
print(f"OUTPUT: {output}")
104+
channel.close()
87105

88106

89107
@pytest.mark.timeout(TIMEOUT_SECS)
90108
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
91109
@pytest.mark.filterwarnings("ignore::ResourceWarning")
92110
def test_exact_jupyterhub_ssh(paramiko_object):
93-
stdin, stdout, stderr = paramiko_object.exec_command("")
94-
95-
# commands to run and exactly match output
96-
commands_exact = [
97-
("id -u", "1000"),
98-
("id -g", "100"),
99-
("whoami", constants.KEYCLOAK_USERNAME),
100-
("pwd", f"/home/{constants.KEYCLOAK_USERNAME}"),
101-
("echo $HOME", f"/home/{constants.KEYCLOAK_USERNAME}"),
102-
("conda activate default && echo $CONDA_PREFIX", "/opt/conda/envs/default"),
103-
(
104-
"hostname",
105-
f"jupyter-{escape_string(constants.KEYCLOAK_USERNAME, safe=set(string.ascii_lowercase + string.digits), escape_char='-').lower()}",
106-
),
107-
]
111+
client, params = paramiko_object
112+
channel = invoke_shell(client, params)
113+
# Commands to run and exactly match output
114+
commands_exact = {
115+
"id -u": "1000",
116+
"id -g": "100",
117+
"whoami": constants.KEYCLOAK_USERNAME,
118+
"pwd": f"/home/{constants.KEYCLOAK_USERNAME}",
119+
"echo $HOME": f"/home/{constants.KEYCLOAK_USERNAME}",
120+
"conda activate default && echo $CONDA_PREFIX": "/opt/conda/envs/default",
121+
"hostname": f"jupyter-{escape_string(constants.KEYCLOAK_USERNAME, safe=set(string.ascii_lowercase + string.digits), escape_char='-').lower()}",
122+
}
123+
outputs = run_command_list(list(commands_exact.keys()), channel)
124+
for command, output in outputs.items():
125+
assert (
126+
output == outputs[command]
127+
), f"Command '{command}' output '{outputs[command]}' does not match expected '{output}'"
108128

109-
for command, output in commands_exact:
110-
assert output == run_command(command, stdin, stdout, stderr)
129+
channel.close()
111130

112131

113132
@pytest.mark.timeout(TIMEOUT_SECS)
114133
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
115134
@pytest.mark.filterwarnings("ignore::ResourceWarning")
116135
def test_contains_jupyterhub_ssh(paramiko_object):
117-
stdin, stdout, stderr = paramiko_object.exec_command("")
118-
119-
# commands to run and string need to be contained in output
120-
commands_contain = [
121-
("ls -la", ".bashrc"),
122-
("cat ~/.bashrc", "Managed by Nebari"),
123-
("cat ~/.profile", "Managed by Nebari"),
124-
("cat ~/.bash_logout", "Managed by Nebari"),
125-
# ensure we don't copy over extra files from /etc/skel in init container
126-
("ls -la ~/..202*", "No such file or directory"),
127-
("ls -la ~/..data", "No such file or directory"),
128-
]
136+
client, params = paramiko_object
137+
channel = invoke_shell(client, params)
138+
139+
# Commands to run and check if the output contains specific strings
140+
commands_contain = {
141+
"ls -la": ".bashrc",
142+
"cat ~/.bashrc": "Managed by Nebari",
143+
"cat ~/.profile": "Managed by Nebari",
144+
"cat ~/.bash_logout": "Managed by Nebari",
145+
# Ensure we don't copy over extra files from /etc/skel in init container
146+
"ls -la ~/..202*": "No such file or directory",
147+
"ls -la ~/..data": "No such file or directory",
148+
}
149+
150+
outputs = run_command_list(commands_contain.keys(), channel, 30)
151+
for command, expected_output in commands_contain.items():
152+
assert (
153+
expected_output in outputs[command]
154+
), f"Command '{command}' output does not contain expected substring '{expected_output}'. Instead got '{outputs[command]}'"
129155

130-
for command, output in commands_contain:
131-
assert output in run_command(command, stdin, stdout, stderr)
156+
channel.close()

0 commit comments

Comments
 (0)