001/*
002 * VM-Operator
003 * Copyright (C) 2023,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 com.google.gson.JsonObject;
022import io.kubernetes.client.Discovery;
023import io.kubernetes.client.Discovery.APIResource;
024import io.kubernetes.client.common.KubernetesListObject;
025import io.kubernetes.client.common.KubernetesObject;
026import io.kubernetes.client.common.KubernetesType;
027import io.kubernetes.client.custom.V1Patch;
028import io.kubernetes.client.openapi.ApiClient;
029import io.kubernetes.client.openapi.ApiException;
030import io.kubernetes.client.openapi.apis.EventsV1Api;
031import io.kubernetes.client.openapi.models.EventsV1Event;
032import io.kubernetes.client.openapi.models.V1ObjectMeta;
033import io.kubernetes.client.openapi.models.V1ObjectReference;
034import io.kubernetes.client.util.Strings;
035import io.kubernetes.client.util.generic.GenericKubernetesApi;
036import io.kubernetes.client.util.generic.KubernetesApiResponse;
037import io.kubernetes.client.util.generic.options.PatchOptions;
038import java.io.Reader;
039import java.net.HttpURLConnection;
040import java.time.OffsetDateTime;
041import java.util.Map;
042import java.util.Optional;
043import org.yaml.snakeyaml.LoaderOptions;
044import org.yaml.snakeyaml.Yaml;
045import org.yaml.snakeyaml.constructor.SafeConstructor;
046
047/**
048 * Helpers for K8s API.
049 */
050@SuppressWarnings({ "PMD.ShortClassName", "PMD.UseUtilityClass",
051    "PMD.DataflowAnomalyAnalysis" })
052public class K8s {
053
054    /**
055     * Returns the result from an API call as {@link Optional} if the
056     * call was successful. Returns an empty `Optional` if the status
057     * code is 404 (not found). Else throws an exception.
058     *
059     * @param <T> the generic type
060     * @param response the response
061     * @return the optional
062     * @throws ApiException the API exception
063     */
064    public static <T extends KubernetesType> Optional<T>
065            optional(KubernetesApiResponse<T> response) throws ApiException {
066        if (response.isSuccess()) {
067            return Optional.of(response.getObject());
068        }
069        if (response.getHttpStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) {
070            return Optional.empty();
071        }
072        response.throwsApiException();
073        // Never reached
074        return Optional.empty();
075    }
076
077    /**
078     * Returns a new context with the given version as preferred version.
079     *
080     * @param context the context
081     * @param version the version
082     * @return the API resource
083     */
084    public static APIResource preferred(APIResource context, String version) {
085        assert context.getVersions().contains(version);
086        return new APIResource(context.getGroup(),
087            context.getVersions(), version, context.getKind(),
088            context.getNamespaced(), context.getResourcePlural(),
089            context.getResourceSingular());
090    }
091
092    /**
093     * Return a string representation of the context (API resource).
094     *
095     * @param context the context
096     * @return the string
097     */
098    @SuppressWarnings("PMD.UseLocaleWithCaseConversions")
099    public static String toString(APIResource context) {
100        return (Strings.isNullOrEmpty(context.getGroup()) ? ""
101            : context.getGroup() + "/")
102            + context.getPreferredVersion().toUpperCase()
103            + context.getKind();
104    }
105
106    /**
107     * Convert Yaml to Json.
108     *
109     * @param client the client
110     * @param yaml the yaml
111     * @return the json element
112     */
113    public static JsonObject yamlToJson(ApiClient client, Reader yaml) {
114        // Avoid Yaml.load due to
115        // https://github.com/kubernetes-client/java/issues/2741
116        @SuppressWarnings("PMD.UseConcurrentHashMap")
117        Map<String, Object> yamlData
118            = new Yaml(new SafeConstructor(new LoaderOptions())).load(yaml);
119
120        // There's no short-cut from Java (collections) to Gson
121        var gson = client.getJSON().getGson();
122        var jsonText = gson.toJson(yamlData);
123        return gson.fromJson(jsonText, JsonObject.class);
124    }
125
126    /**
127     * Lookup the specified API resource. If the version is `null` or
128     * empty, the preferred version in the result is the default
129     * returned from the server.
130     *
131     * @param client the client
132     * @param group the group
133     * @param version the version
134     * @param kind the kind
135     * @return the optional
136     * @throws ApiException the api exception
137     */
138    public static Optional<APIResource> context(ApiClient client,
139            String group, String version, String kind) throws ApiException {
140        var apiMatch = new Discovery(client).findAll().stream()
141            .filter(r -> r.getGroup().equals(group) && r.getKind().equals(kind)
142                && (Strings.isNullOrEmpty(version)
143                    || r.getVersions().contains(version)))
144            .findFirst();
145        if (apiMatch.isEmpty()) {
146            return Optional.empty();
147        }
148        var apiRes = apiMatch.get();
149        if (!Strings.isNullOrEmpty(version)) {
150            if (!apiRes.getVersions().contains(version)) {
151                return Optional.empty();
152            }
153            apiRes = new APIResource(apiRes.getGroup(), apiRes.getVersions(),
154                version, apiRes.getKind(), apiRes.getNamespaced(),
155                apiRes.getResourcePlural(), apiRes.getResourceSingular());
156        }
157        return Optional.of(apiRes);
158    }
159
160    /**
161     * Get an object from its metadata.
162     *
163     * @param <T> the generic type
164     * @param <LT> the generic type
165     * @param api the api
166     * @param meta the meta
167     * @return the object
168     */
169    @Deprecated
170    @SuppressWarnings("PMD.GenericsNaming")
171    public static <T extends KubernetesObject, LT extends KubernetesListObject>
172            Optional<T>
173            get(GenericKubernetesApi<T, LT> api, V1ObjectMeta meta) {
174        var response = api.get(meta.getNamespace(), meta.getName());
175        if (response.isSuccess()) {
176            return Optional.of(response.getObject());
177        }
178        return Optional.empty();
179    }
180
181    /**
182     * Apply the given patch data.
183     *
184     * @param <T> the generic type
185     * @param <LT> the generic type
186     * @param api the api
187     * @param existing the existing
188     * @param update the update
189     * @return the t
190     * @throws ApiException the api exception
191     */
192    @SuppressWarnings("PMD.GenericsNaming")
193    public static <T extends KubernetesObject, LT extends KubernetesListObject>
194            T apply(GenericKubernetesApi<T, LT> api, T existing, String update)
195                    throws ApiException {
196        PatchOptions opts = new PatchOptions();
197        opts.setForce(true);
198        opts.setFieldManager("kubernetes-java-kubectl-apply");
199        var response = api.patch(existing.getMetadata().getNamespace(),
200            existing.getMetadata().getName(), V1Patch.PATCH_FORMAT_APPLY_YAML,
201            new V1Patch(update), opts).throwsApiException();
202        return response.getObject();
203    }
204
205    /**
206     * Create an object reference.
207     *
208     * @param object the object
209     * @return the v 1 object reference
210     */
211    public static V1ObjectReference
212            objectReference(KubernetesObject object) {
213        return new V1ObjectReference().apiVersion(object.getApiVersion())
214            .kind(object.getKind())
215            .namespace(object.getMetadata().getNamespace())
216            .name(object.getMetadata().getName())
217            .resourceVersion(object.getMetadata().getResourceVersion())
218            .uid(object.getMetadata().getUid());
219    }
220
221    /**
222     * Creates an event related to the object, adding reasonable defaults.
223     * 
224     *   * If `kind` is not set, it is set to "Event".
225     *   * If `metadata.namespace` is not set, it is set 
226     *     to the object's namespace.
227     *   * If neither `metadata.name` nor `matadata.generateName` are set,
228     *     set `generateName` to the object's name with a dash appended.
229     *   * If `reportingInstance` is not set, set it to the object's name.
230     *   * If `eventTime` is not set, set it to now.
231     *   * If `type` is not set, set it to "Normal"
232     *   * If `regarding` is not set, set it to the given object.
233     *
234     * @param client the client
235     * @param object the object
236     * @param event the event
237     * @throws ApiException the api exception
238     */
239    @SuppressWarnings("PMD.NPathComplexity")
240    public static void createEvent(ApiClient client,
241            KubernetesObject object, EventsV1Event event)
242            throws ApiException {
243        if (Strings.isNullOrEmpty(event.getKind())) {
244            event.kind("Event");
245        }
246        if (event.getMetadata() == null) {
247            event.metadata(new V1ObjectMeta());
248        }
249        if (Strings.isNullOrEmpty(event.getMetadata().getNamespace())) {
250            event.getMetadata().namespace(object.getMetadata().getNamespace());
251        }
252        if (Strings.isNullOrEmpty(event.getMetadata().getName())
253            && Strings.isNullOrEmpty(event.getMetadata().getGenerateName())) {
254            event.getMetadata()
255                .generateName(object.getMetadata().getName() + "-");
256        }
257        if (Strings.isNullOrEmpty(event.getReportingInstance())) {
258            event.reportingInstance(object.getMetadata().getName());
259        }
260        if (event.getEventTime() == null) {
261            event.eventTime(OffsetDateTime.now());
262        }
263        if (Strings.isNullOrEmpty(event.getType())) {
264            event.type("Normal");
265        }
266        if (event.getRegarding() == null) {
267            event.regarding(objectReference(object));
268        }
269        new EventsV1Api(client).createNamespacedEvent(
270            object.getMetadata().getNamespace(), event, null, null, null, null);
271    }
272}