001/*
002 * VM-Operator
003 * Copyright (C) 2024 Michael N. Lipp
004 * 
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU Affero General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
013 * GNU Affero General Public License for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License
016 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
017 */
018
019package org.jdrupes.vmoperator.common;
020
021import io.kubernetes.client.Discovery.APIResource;
022import io.kubernetes.client.apimachinery.GroupVersionKind;
023import io.kubernetes.client.common.KubernetesListObject;
024import io.kubernetes.client.common.KubernetesObject;
025import io.kubernetes.client.custom.V1Patch;
026import io.kubernetes.client.openapi.ApiException;
027import io.kubernetes.client.util.Strings;
028import io.kubernetes.client.util.generic.GenericKubernetesApi;
029import io.kubernetes.client.util.generic.KubernetesApiResponse;
030import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
031import io.kubernetes.client.util.generic.options.GetOptions;
032import io.kubernetes.client.util.generic.options.ListOptions;
033import io.kubernetes.client.util.generic.options.PatchOptions;
034import io.kubernetes.client.util.generic.options.UpdateOptions;
035import java.net.HttpURLConnection;
036import java.util.ArrayList;
037import java.util.Collection;
038import java.util.LinkedList;
039import java.util.List;
040import java.util.Optional;
041import java.util.function.Function;
042
043/**
044 * A stub for namespaced custom objects. This stub provides the
045 * functions common to all Kubernetes objects, but uses variables
046 * for all types. This class should be used as base class only.
047 *
048 * @param <O> the generic type
049 * @param <L> the generic type
050 */
051@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyMethods" })
052public class K8sGenericStub<O extends KubernetesObject,
053        L extends KubernetesListObject> {
054    protected final K8sClient client;
055    private final GenericKubernetesApi<O, L> api;
056    protected final APIResource context;
057    protected final String namespace;
058    protected final String name;
059
060    /**
061     * Instantiates a new stub for the object specified. If the object
062     * exists in the context specified, the version (see
063     * {@link #version()} is bound to the existing object's version.
064     * Else the stub is dangling with the version set to the context's
065     * preferred version.
066     *
067     * @param objectClass the object class
068     * @param objectListClass the object list class
069     * @param client the client
070     * @param context the context
071     * @param namespace the namespace
072     * @param name the name
073     */
074    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
075    protected K8sGenericStub(Class<O> objectClass, Class<L> objectListClass,
076            K8sClient client, APIResource context, String namespace,
077            String name) {
078        this.client = client;
079        this.namespace = namespace;
080        this.name = name;
081
082        // Bind version
083        var foundVersion = context.getPreferredVersion();
084        GenericKubernetesApi<O, L> testApi = null;
085        GetOptions mdOpts
086            = new GetOptions().isPartialObjectMetadataRequest(true);
087        for (var version : candidateVersions(context)) {
088            testApi = new GenericKubernetesApi<>(objectClass, objectListClass,
089                context.getGroup(), version, context.getResourcePlural(),
090                client);
091            if (testApi.get(namespace, name, mdOpts)
092                .isSuccess()) {
093                foundVersion = version;
094                break;
095            }
096        }
097        if (foundVersion.equals(context.getPreferredVersion())) {
098            this.context = context;
099        } else {
100            this.context = K8s.preferred(context, foundVersion);
101        }
102
103        api = Optional.ofNullable(testApi)
104            .orElseGet(() -> new GenericKubernetesApi<>(objectClass,
105                objectListClass, group(), version(), plural(), client));
106    }
107
108    /**
109     * Gets the context.
110     *
111     * @return the context
112     */
113    public APIResource context() {
114        return context;
115    }
116
117    /**
118     * Gets the group.
119     *
120     * @return the group
121     */
122    public String group() {
123        return context.getGroup();
124    }
125
126    /**
127     * Gets the version.
128     *
129     * @return the version
130     */
131    public String version() {
132        return context.getPreferredVersion();
133    }
134
135    /**
136     * Gets the kind.
137     *
138     * @return the kind
139     */
140    public String kind() {
141        return context.getKind();
142    }
143
144    /**
145     * Gets the plural.
146     *
147     * @return the plural
148     */
149    public String plural() {
150        return context.getResourcePlural();
151    }
152
153    /**
154     * Gets the namespace.
155     *
156     * @return the namespace
157     */
158    public String namespace() {
159        return namespace;
160    }
161
162    /**
163     * Gets the name.
164     *
165     * @return the name
166     */
167    public String name() {
168        return name;
169    }
170
171    /**
172     * Delete the Kubernetes object.
173     *
174     * @throws ApiException the API exception
175     */
176    public void delete() throws ApiException {
177        var result = api.delete(namespace, name);
178        if (result.isSuccess()
179            || result.getHttpStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) {
180            return;
181        }
182        result.throwsApiException();
183    }
184
185    /**
186     * Retrieves and returns the current state of the object.
187     *
188     * @return the object's state
189     * @throws ApiException the api exception
190     */
191    public Optional<O> model() throws ApiException {
192        return K8s.optional(api.get(namespace, name));
193    }
194
195    /**
196     * Updates the object's status. Does not retry in case of conflict.
197     *
198     * @param object the current state of the object (passed to `status`)
199     * @param updater function that returns the new status
200     * @return the updated model or empty if the object was not found
201     * @throws ApiException the api exception
202     */
203    @SuppressWarnings("PMD.AssignmentInOperand")
204    public Optional<O> updateStatus(O object, Function<O, Object> updater)
205            throws ApiException {
206        return K8s.optional(api.updateStatus(object, updater));
207    }
208
209    /**
210     * Updates the status of the given object. In case of conflict,
211     * get the current version of the object and tries again. Retries
212     * up to `retries` times.
213     *
214     * @param updater the function updating the status
215     * @param current the current state of the object, used for the first
216     * attempt to update
217     * @param retries the retries in case of conflict
218     * @return the updated model or empty if the object was not found
219     * @throws ApiException the api exception
220     */
221    @SuppressWarnings({ "PMD.AssignmentInOperand", "PMD.UnusedAssignment" })
222    public Optional<O> updateStatus(Function<O, Object> updater, O current,
223            int retries) throws ApiException {
224        while (true) {
225            try {
226                if (current == null) {
227                    current = api.get(namespace, name)
228                        .throwsApiException().getObject();
229                }
230                return updateStatus(current, updater);
231            } catch (ApiException e) {
232                if (HttpURLConnection.HTTP_CONFLICT != e.getCode()
233                    || retries-- <= 0) {
234                    throw e;
235                }
236                // Get current version for new attempt
237                current = null;
238            }
239        }
240    }
241
242    /**
243     * Gets the object and updates the status. In case of conflict, retries
244     * up to `retries` times.
245     *
246     * @param updater the function updating the status
247     * @param retries the retries in case of conflict
248     * @return the updated model or empty if the object was not found
249     * @throws ApiException the api exception
250     */
251    @SuppressWarnings({ "PMD.AssignmentInOperand", "PMD.UnusedAssignment" })
252    public Optional<O> updateStatus(Function<O, Object> updater, int retries)
253            throws ApiException {
254        return updateStatus(updater, null, retries);
255    }
256
257    /**
258     * Updates the status of the given object. In case of conflict,
259     * get the current version of the object and tries again. Retries
260     * up to `retries` times.
261     *
262     * @param updater the function updating the status
263     * @param current the current
264     * @return the kubernetes api response
265     * the updated model or empty if not successful
266     * @throws ApiException the api exception
267     */
268    public Optional<O> updateStatus(Function<O, Object> updater, O current)
269            throws ApiException {
270        return updateStatus(updater, current, 16);
271    }
272
273    /**
274     * Updates the status. In case of conflict, retries up to 16 times.
275     *
276     * @param updater the function updating the status
277     * @return the kubernetes api response
278     * the updated model or empty if not successful
279     * @throws ApiException the api exception
280     */
281    public Optional<O> updateStatus(Function<O, Object> updater)
282            throws ApiException {
283        return updateStatus(updater, null);
284    }
285
286    /**
287     * Patch the object.
288     *
289     * @param patchType the patch type
290     * @param patch the patch
291     * @param options the options
292     * @return the kubernetes api response if successful
293     * @throws ApiException the api exception
294     */
295    public Optional<O> patch(String patchType, V1Patch patch,
296            PatchOptions options) throws ApiException {
297        return K8s
298            .optional(api.patch(namespace, name, patchType, patch, options)
299                .throwsApiException());
300    }
301
302    /**
303     * Patch the object using default options.
304     *
305     * @param patchType the patch type
306     * @param patch the patch
307     * @return the kubernetes api response if successful
308     * @throws ApiException the api exception
309     */
310    public Optional<O>
311            patch(String patchType, V1Patch patch) throws ApiException {
312        PatchOptions opts = new PatchOptions();
313        return patch(patchType, patch, opts);
314    }
315
316    /**
317     * Apply the given definition. 
318     *
319     * @param def the def
320     * @return the kubernetes api response if successful
321     * @throws ApiException the api exception
322     */
323    public Optional<O> apply(DynamicKubernetesObject def) throws ApiException {
324        PatchOptions opts = new PatchOptions();
325        opts.setForce(true);
326        opts.setFieldManager("kubernetes-java-kubectl-apply");
327        return patch(V1Patch.PATCH_FORMAT_APPLY_YAML,
328            new V1Patch(client.getJSON().serialize(def)), opts);
329    }
330
331    /**
332     * Update the object.
333     *
334     * @param object the object
335     * @return the kubernetes api response
336     * @throws ApiException the api exception
337     */
338    public KubernetesApiResponse<O> update(O object) throws ApiException {
339        return api.update(object).throwsApiException();
340    }
341
342    /**
343     * Update the object.
344     *
345     * @param object the object
346     * @param options the options
347     * @return the kubernetes api response
348     * @throws ApiException the api exception
349     */
350    public KubernetesApiResponse<O> update(O object, UpdateOptions options)
351            throws ApiException {
352        return api.update(object, options).throwsApiException();
353    }
354
355    /**
356     * A supplier for generic stubs.
357     *
358     * @param <O> the object type
359     * @param <L> the object list type
360     * @param <R> the result type
361     */
362    public interface GenericSupplier<O extends KubernetesObject,
363            L extends KubernetesListObject, R extends K8sGenericStub<O, L>> {
364
365        /**
366         * Gets a new stub.
367         *
368         * @param client the client
369         * @param namespace the namespace
370         * @param name the name
371         * @return the result
372         */
373        @SuppressWarnings("PMD.UseObjectForClearerAPI")
374        R get(K8sClient client, String namespace, String name);
375    }
376
377    @Override
378    @SuppressWarnings("PMD.UseLocaleWithCaseConversions")
379    public String toString() {
380        return (Strings.isNullOrEmpty(group()) ? "" : group() + "/")
381            + version().toUpperCase() + kind() + " " + namespace + ":" + name;
382    }
383
384    /**
385     * Get a namespaced object stub for a newly created object.
386     *
387     * @param <O> the object type
388     * @param <L> the object list type
389     * @param <R> the stub type
390     * @param objectClass the object class
391     * @param objectListClass the object list class
392     * @param client the client
393     * @param context the context
394     * @param model the model
395     * @param provider the provider
396     * @return the stub if the object exists
397     * @throws ApiException the api exception
398     */
399    @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop",
400        "PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" })
401    public static <O extends KubernetesObject, L extends KubernetesListObject,
402            R extends K8sGenericStub<O, L>>
403            R create(Class<O> objectClass, Class<L> objectListClass,
404                    K8sClient client, APIResource context, O model,
405                    GenericSupplier<O, L, R> provider) throws ApiException {
406        var api = new GenericKubernetesApi<>(objectClass, objectListClass,
407            context.getGroup(), context.getPreferredVersion(),
408            context.getResourcePlural(), client);
409        api.create(model).throwsApiException();
410        return provider.get(client, model.getMetadata().getNamespace(),
411            model.getMetadata().getName());
412    }
413
414    /**
415     * Get the stubs for the objects in the given namespace that match
416     * the criteria from the given options.
417     *
418     * @param <O> the object type
419     * @param <L> the object list type
420     * @param <R> the stub type
421     * @param objectClass the object class
422     * @param objectListClass the object list class
423     * @param client the client
424     * @param context the context
425     * @param namespace the namespace
426     * @param options the options
427     * @param provider the provider
428     * @return the collection
429     * @throws ApiException the api exception
430     */
431    public static <O extends KubernetesObject, L extends KubernetesListObject,
432            R extends K8sGenericStub<O, L>>
433            Collection<R> list(Class<O> objectClass, Class<L> objectListClass,
434                    K8sClient client, APIResource context, String namespace,
435                    ListOptions options, GenericSupplier<O, L, R> provider)
436                    throws ApiException {
437        var result = new ArrayList<R>();
438        for (var version : candidateVersions(context)) {
439            @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
440            var api = new GenericKubernetesApi<>(objectClass, objectListClass,
441                context.getGroup(), version, context.getResourcePlural(),
442                client);
443            var objs = api.list(namespace, options).throwsApiException();
444            for (var item : objs.getObject().getItems()) {
445                result.add(provider.get(client, namespace,
446                    item.getMetadata().getName()));
447            }
448        }
449        return result;
450    }
451
452    private static List<String> candidateVersions(APIResource context) {
453        var result = new LinkedList<>(context.getVersions());
454        result.remove(context.getPreferredVersion());
455        result.add(0, context.getPreferredVersion());
456        return result;
457    }
458
459    /**
460     * Api resource.
461     *
462     * @param client the client
463     * @param gvk the gvk
464     * @return the API resource
465     * @throws ApiException the api exception
466     */
467    public static APIResource apiResource(K8sClient client,
468            GroupVersionKind gvk) throws ApiException {
469        var context = K8s.context(client, gvk.getGroup(), gvk.getVersion(),
470            gvk.getKind());
471        if (context.isEmpty()) {
472            throw new ApiException("No known API for " + gvk.getGroup()
473                + "/" + gvk.getVersion() + " " + gvk.getKind());
474        }
475        return context.get();
476    }
477
478}