Skip to content

Commit 030f335

Browse files
feat(cloud): add runnable K8s cluster example
* feat(cloud): add runnable K8s cluster example Cover the full cluster lifecycle against the live API: cluster create/get/list/update/upgrade/delete (with *_and_poll), certificate + kubeconfig retrieval, pool CRUD with resize and quota check, and node listing for both cluster and pool scopes. Addresses GCLOUD2-24866 (Python SDK portion). * feat(cloud/examples): exercise upgrade path in k8s example Pick the penultimate version for cluster creation so an upgrade target is always available, and add a wait_for_cluster_ready helper invoked after upgrade_and_poll — the upgrade task finishes before the cluster itself settles back to Provisioned, so subsequent mutations (e.g. creating a new pool) can otherwise be rejected with "Cluster or pool is not ready".
1 parent 76bd4c9 commit 030f335

2 files changed

Lines changed: 766 additions & 0 deletions

File tree

examples/cloud/k8s_clusters.py

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
import os
2+
import time
3+
from typing import List
4+
5+
from gcore import Gcore
6+
from gcore.types.cloud.baremetal_flavor import BaremetalFlavor
7+
from gcore.types.cloud.k8s_cluster_version import K8SClusterVersion
8+
from gcore.types.cloud.k8s.cluster_create_params import Pool
9+
10+
11+
def main() -> None:
12+
# TODO set API key before running
13+
# api_key = os.environ["GCORE_API_KEY"]
14+
# TODO set cloud project ID before running
15+
# cloud_project_id = os.environ["GCORE_CLOUD_PROJECT_ID"]
16+
# TODO set cloud region ID before running
17+
# cloud_region_id = os.environ["GCORE_CLOUD_REGION_ID"]
18+
# TODO set a pre-existing SSH keypair name before running. The K8s cluster
19+
# create endpoint requires a keypair to bootstrap nodes.
20+
keypair_name = os.environ["GCORE_CLOUD_K8S_KEYPAIR_NAME"]
21+
22+
gcore = Gcore(
23+
# No need to explicitly pass to Gcore constructor if using environment variables
24+
# api_key=api_key,
25+
# cloud_project_id=cloud_project_id,
26+
# cloud_region_id=cloud_region_id,
27+
)
28+
29+
# Look up a small non-GPU flavor and the latest version to use for cluster creation.
30+
flavors = list_flavors(client=gcore)
31+
pool_flavor_id = _pick_pool_flavor(flavors)
32+
33+
versions = list_versions(client=gcore)
34+
cluster_version = _pick_create_version(versions)
35+
36+
# Cluster lifecycle.
37+
cluster_name = create_cluster(
38+
client=gcore,
39+
flavor_id=pool_flavor_id,
40+
version=cluster_version,
41+
keypair_name=keypair_name,
42+
)
43+
44+
get_cluster(client=gcore, cluster_name=cluster_name)
45+
list_clusters(client=gcore)
46+
update_cluster(client=gcore, cluster_name=cluster_name)
47+
48+
# Upgrade the cluster to the newest available version, if one exists.
49+
upgrade_versions = list_upgrade_versions(client=gcore, cluster_name=cluster_name)
50+
if upgrade_versions:
51+
upgrade_cluster(client=gcore, cluster_name=cluster_name, target_version=upgrade_versions[-1].version)
52+
# The upgrade task finishes before the cluster itself settles back to
53+
# Provisioned, so subsequent mutations (e.g. creating a new pool) can
54+
# be rejected with "Cluster or pool is not ready". Wait it out.
55+
wait_for_cluster_ready(client=gcore, cluster_name=cluster_name)
56+
else:
57+
print(f"No upgrade versions available for cluster {cluster_name}; skipping upgrade")
58+
59+
get_certificate(client=gcore, cluster_name=cluster_name)
60+
get_kubeconfig(client=gcore, cluster_name=cluster_name)
61+
62+
# Pool operations.
63+
list_pools(client=gcore, cluster_name=cluster_name)
64+
first_pool = _get_first_pool(client=gcore, cluster_name=cluster_name)
65+
66+
check_pool_quota(client=gcore, flavor_id=pool_flavor_id)
67+
second_pool = create_pool(client=gcore, cluster_name=cluster_name, flavor_id=pool_flavor_id)
68+
update_pool(client=gcore, cluster_name=cluster_name, pool_name=second_pool)
69+
resize_pool(client=gcore, cluster_name=cluster_name, pool_name=second_pool)
70+
list_pool_nodes(client=gcore, cluster_name=cluster_name, pool_name=second_pool)
71+
delete_pool(client=gcore, cluster_name=cluster_name, pool_name=second_pool)
72+
73+
# Ensure the initial pool is still reachable after the second pool is gone.
74+
get_pool(client=gcore, cluster_name=cluster_name, pool_name=first_pool)
75+
76+
list_cluster_nodes(client=gcore, cluster_name=cluster_name)
77+
78+
# Final cleanup.
79+
delete_cluster(client=gcore, cluster_name=cluster_name)
80+
81+
82+
def create_cluster(*, client: Gcore, flavor_id: str, version: str, keypair_name: str) -> str:
83+
print("\n=== CREATE K8S CLUSTER ===")
84+
cluster = client.cloud.k8s.clusters.create_and_poll(
85+
name="gcore-py-example-k8s",
86+
keypair=keypair_name,
87+
version=version,
88+
pools=[
89+
Pool(
90+
name="gcore-python-pool",
91+
flavor_id=flavor_id,
92+
min_node_count=1,
93+
max_node_count=2,
94+
boot_volume_size=50,
95+
boot_volume_type="standard",
96+
auto_healing_enabled=True,
97+
is_public_ipv4=True,
98+
servergroup_policy="soft-anti-affinity",
99+
labels={"pool": "gcore-python-example"},
100+
),
101+
],
102+
)
103+
print(f"Created K8s cluster: name={cluster.name}, version={cluster.version}, status={cluster.status}")
104+
print("==========================")
105+
return cluster.name
106+
107+
108+
def get_cluster(*, client: Gcore, cluster_name: str) -> None:
109+
print("\n=== GET K8S CLUSTER ===")
110+
cluster = client.cloud.k8s.clusters.get(cluster_name=cluster_name)
111+
print(
112+
f"K8s cluster: name={cluster.name}, version={cluster.version}, "
113+
f"status={cluster.status}, pools={len(cluster.pools)}"
114+
)
115+
print("=======================")
116+
117+
118+
def list_clusters(*, client: Gcore) -> None:
119+
print("\n=== LIST K8S CLUSTERS ===")
120+
clusters = client.cloud.k8s.clusters.list()
121+
if not clusters.results:
122+
print(" No k8s clusters found.")
123+
for count, cluster in enumerate(clusters.results, 1):
124+
print(f" {count}. K8s cluster: name={cluster.name}, version={cluster.version}, status={cluster.status}")
125+
print("=========================")
126+
127+
128+
def update_cluster(*, client: Gcore, cluster_name: str) -> None:
129+
print("\n=== UPDATE K8S CLUSTER ===")
130+
# Override a cluster-autoscaler parameter — a safe, idempotent change that
131+
# exercises update_and_poll.
132+
cluster = client.cloud.k8s.clusters.update_and_poll(
133+
cluster_name=cluster_name,
134+
autoscaler_config={"scale-down-unneeded-time": "5m"},
135+
)
136+
print(f"Updated K8s cluster: name={cluster.name}, autoscaler_config={cluster.autoscaler_config}")
137+
print("==========================")
138+
139+
140+
def list_upgrade_versions(*, client: Gcore, cluster_name: str) -> List[K8SClusterVersion]:
141+
print("\n=== LIST K8S CLUSTER UPGRADE VERSIONS ===")
142+
versions = client.cloud.k8s.clusters.list_versions_for_upgrade(cluster_name=cluster_name)
143+
for count, version in enumerate(versions.results, 1):
144+
print(f" {count}. Upgrade version: {version.version}")
145+
if not versions.results:
146+
print(" Cluster is already on the latest available version.")
147+
print("==========================================")
148+
return versions.results
149+
150+
151+
def upgrade_cluster(*, client: Gcore, cluster_name: str, target_version: str) -> None:
152+
print("\n=== UPGRADE K8S CLUSTER ===")
153+
cluster = client.cloud.k8s.clusters.upgrade_and_poll(cluster_name=cluster_name, version=target_version)
154+
print(f"Upgraded K8s cluster: name={cluster.name}, version={cluster.version}")
155+
print("===========================")
156+
157+
158+
def get_certificate(*, client: Gcore, cluster_name: str) -> None:
159+
print("\n=== GET K8S CLUSTER CERTIFICATE ===")
160+
cert = client.cloud.k8s.clusters.get_certificate(cluster_name=cluster_name)
161+
print(f"Certificate bytes={len(cert.certificate)}, Key bytes={len(cert.key)}")
162+
print("===================================")
163+
164+
165+
def wait_for_cluster_ready(*, client: Gcore, cluster_name: str) -> None:
166+
print("\n=== WAIT FOR K8S CLUSTER READY ===")
167+
poll_interval = 10
168+
max_attempts = 60
169+
for attempt in range(1, max_attempts + 1):
170+
cluster = client.cloud.k8s.clusters.get(cluster_name=cluster_name)
171+
pending: List[str] = []
172+
if cluster.status != "Provisioned":
173+
pending.append(f"cluster={cluster.status}")
174+
for pool in cluster.pools:
175+
if pool.status != "Running":
176+
pending.append(f"pool[{pool.name}]={pool.status}")
177+
if not pending:
178+
print(f"Cluster {cluster_name} is ready (all pools Running)")
179+
print("==================================")
180+
return
181+
print(f" attempt {attempt}: waiting on {' '.join(pending)}")
182+
time.sleep(poll_interval)
183+
raise TimeoutError(f"Cluster {cluster_name} did not become ready after {max_attempts} attempts")
184+
185+
186+
def get_kubeconfig(*, client: Gcore, cluster_name: str) -> None:
187+
print("\n=== GET K8S KUBECONFIG ===")
188+
kubeconfig = client.cloud.k8s.clusters.kubeconfig.get(cluster_name=cluster_name)
189+
print(
190+
f"Kubeconfig: host={kubeconfig.host}, created_at={kubeconfig.created_at}, "
191+
f"expires_at={kubeconfig.expires_at}, bytes={len(kubeconfig.config)}"
192+
)
193+
print("==========================")
194+
195+
196+
def list_pools(*, client: Gcore, cluster_name: str) -> None:
197+
print("\n=== LIST K8S CLUSTER POOLS ===")
198+
pools = client.cloud.k8s.clusters.pools.list(cluster_name=cluster_name)
199+
if not pools.results:
200+
print(" No pools found.")
201+
for count, pool in enumerate(pools.results, 1):
202+
print(
203+
f" {count}. Pool: name={pool.name}, flavor={pool.flavor_id}, "
204+
f"node_count={pool.node_count}, status={pool.status}"
205+
)
206+
print("==============================")
207+
208+
209+
def get_pool(*, client: Gcore, cluster_name: str, pool_name: str) -> None:
210+
print("\n=== GET K8S CLUSTER POOL ===")
211+
pool = client.cloud.k8s.clusters.pools.get(pool_name=pool_name, cluster_name=cluster_name)
212+
print(
213+
f"Pool: name={pool.name}, flavor={pool.flavor_id}, node_count={pool.node_count}, "
214+
f"min={pool.min_node_count}, max={pool.max_node_count}, status={pool.status}"
215+
)
216+
print("============================")
217+
218+
219+
def check_pool_quota(*, client: Gcore, flavor_id: str) -> None:
220+
print("\n=== CHECK K8S POOL QUOTA ===")
221+
quota = client.cloud.k8s.clusters.pools.check_quota(
222+
flavor_id=flavor_id,
223+
name="gcore-python-pool-2",
224+
min_node_count=1,
225+
max_node_count=2,
226+
boot_volume_size=50,
227+
servergroup_policy="soft-anti-affinity",
228+
)
229+
print(f"CPU: limit={quota.cpu_count_limit}, requested={quota.cpu_count_requested}, usage={quota.cpu_count_usage}")
230+
print(f"RAM (MiB): limit={quota.ram_limit}, requested={quota.ram_requested}, usage={quota.ram_usage}")
231+
print(
232+
f"Volumes: count_limit={quota.volume_count_limit}, count_usage={quota.volume_count_usage}, "
233+
f"size_limit={quota.volume_size_limit} GiB"
234+
)
235+
print("============================")
236+
237+
238+
def create_pool(*, client: Gcore, cluster_name: str, flavor_id: str) -> str:
239+
print("\n=== CREATE K8S CLUSTER POOL ===")
240+
pool = client.cloud.k8s.clusters.pools.create_and_poll(
241+
cluster_name=cluster_name,
242+
name="gcore-python-pool-2",
243+
flavor_id=flavor_id,
244+
min_node_count=1,
245+
max_node_count=2,
246+
boot_volume_size=50,
247+
boot_volume_type="standard",
248+
auto_healing_enabled=True,
249+
is_public_ipv4=True,
250+
servergroup_policy="soft-anti-affinity",
251+
labels={"pool": "gcore-python-example-2"},
252+
)
253+
print(f"Created pool: name={pool.name}, flavor={pool.flavor_id}, node_count={pool.node_count}")
254+
print("===============================")
255+
return pool.name
256+
257+
258+
def update_pool(*, client: Gcore, cluster_name: str, pool_name: str) -> None:
259+
print("\n=== UPDATE K8S CLUSTER POOL ===")
260+
pool = client.cloud.k8s.clusters.pools.update(
261+
pool_name=pool_name,
262+
cluster_name=cluster_name,
263+
labels={"pool": "gcore-python-example-2", "stage": "updated"},
264+
)
265+
print(f"Updated pool: name={pool.name}, labels={pool.labels}")
266+
print("===============================")
267+
268+
269+
def resize_pool(*, client: Gcore, cluster_name: str, pool_name: str) -> None:
270+
print("\n=== RESIZE K8S CLUSTER POOL ===")
271+
pool = client.cloud.k8s.clusters.pools.resize_and_poll(
272+
pool_name=pool_name,
273+
cluster_name=cluster_name,
274+
node_count=2,
275+
)
276+
print(f"Resized pool: name={pool.name}, node_count={pool.node_count}")
277+
print("===============================")
278+
279+
280+
def delete_pool(*, client: Gcore, cluster_name: str, pool_name: str) -> None:
281+
print("\n=== DELETE K8S CLUSTER POOL ===")
282+
client.cloud.k8s.clusters.pools.delete_and_poll(pool_name=pool_name, cluster_name=cluster_name)
283+
print(f"Deleted pool: name={pool_name}")
284+
print("===============================")
285+
286+
287+
def list_cluster_nodes(*, client: Gcore, cluster_name: str) -> None:
288+
print("\n=== LIST K8S CLUSTER NODES ===")
289+
nodes = client.cloud.k8s.clusters.nodes.list(cluster_name=cluster_name)
290+
if not nodes.results:
291+
print(" No nodes found.")
292+
for count, node in enumerate(nodes.results, 1):
293+
print(f" {count}. Node: ID={node.id}, name={node.name}, status={node.status}")
294+
print("==============================")
295+
296+
297+
def list_pool_nodes(*, client: Gcore, cluster_name: str, pool_name: str) -> None:
298+
print("\n=== LIST K8S POOL NODES ===")
299+
nodes = client.cloud.k8s.clusters.pools.nodes.list(pool_name=pool_name, cluster_name=cluster_name)
300+
if not nodes.results:
301+
print(" No pool nodes found.")
302+
for count, node in enumerate(nodes.results, 1):
303+
print(f" {count}. Node: ID={node.id}, name={node.name}, status={node.status}")
304+
print("===========================")
305+
306+
307+
def delete_cluster(*, client: Gcore, cluster_name: str) -> None:
308+
print("\n=== DELETE K8S CLUSTER ===")
309+
client.cloud.k8s.clusters.delete_and_poll(cluster_name=cluster_name)
310+
print(f"Deleted K8s cluster: name={cluster_name}")
311+
print("==========================")
312+
313+
314+
def list_flavors(*, client: Gcore) -> List[BaremetalFlavor]:
315+
print("\n=== LIST K8S FLAVORS ===")
316+
flavors = client.cloud.k8s.flavors.list(exclude_gpu=True, include_capacity=True)
317+
318+
display_count = min(3, len(flavors.results))
319+
for i in range(display_count):
320+
flavor = flavors.results[i]
321+
print(
322+
f" {i + 1}. Flavor: ID={flavor.flavor_id}, name={flavor.flavor_name}, "
323+
f"RAM={flavor.ram} MB, VCPUs={flavor.vcpus}"
324+
)
325+
326+
if len(flavors.results) > display_count:
327+
print(f" ... and {len(flavors.results) - display_count} more flavors")
328+
329+
print(f"Total k8s flavors: {len(flavors.results)}")
330+
print("========================")
331+
return flavors.results
332+
333+
334+
def list_versions(*, client: Gcore) -> List[K8SClusterVersion]:
335+
print("\n=== LIST K8S VERSIONS ===")
336+
versions = client.cloud.k8s.list_versions()
337+
for count, version in enumerate(versions.results, 1):
338+
print(f" {count}. Version: {version.version}")
339+
print(f"Total k8s versions: {len(versions.results)}")
340+
print("=========================")
341+
return versions.results
342+
343+
344+
def _pick_pool_flavor(flavors: List[BaremetalFlavor]) -> str:
345+
# Prefer the smallest g1-standard VM flavor with capacity; skip disabled,
346+
# zero-capacity, and test/fake flavors.
347+
candidates = [
348+
f
349+
for f in flavors
350+
if not f.disabled
351+
and (f.capacity or 0) > 0
352+
and "test" not in f.flavor_name
353+
and "fake" not in f.flavor_name
354+
and f.flavor_name.startswith("g1-standard-")
355+
]
356+
if not candidates:
357+
raise ValueError("No suitable g1-standard k8s flavor with capacity found")
358+
359+
pick = min(candidates, key=lambda f: f.ram)
360+
print(f"Selected k8s pool flavor: {pick.flavor_name} (RAM: {pick.ram} MB, VCPUs: {pick.vcpus})")
361+
return pick.flavor_id
362+
363+
364+
def _pick_create_version(versions: List[K8SClusterVersion]) -> str:
365+
if not versions:
366+
raise ValueError("No k8s versions available for cluster creation")
367+
# The API returns versions in ascending order. Pick the penultimate
368+
# version when possible so the example always has a newer version to
369+
# upgrade to later. Fall back to the only available version otherwise.
370+
idx = max(len(versions) - 2, 0)
371+
picked = versions[idx].version
372+
print(f"Selected k8s version for creation: {picked}")
373+
return picked
374+
375+
376+
def _get_first_pool(*, client: Gcore, cluster_name: str) -> str:
377+
pools = client.cloud.k8s.clusters.pools.list(cluster_name=cluster_name)
378+
if not pools.results:
379+
raise ValueError(f"Cluster {cluster_name} has no pools")
380+
return pools.results[0].name
381+
382+
383+
if __name__ == "__main__":
384+
main()

0 commit comments

Comments
 (0)