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