aboutsummaryrefslogtreecommitdiffstats
path: root/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/horizon/HorizonApiHandler.java
blob: 4f12f00eace45106f35a9d7f5bb4f40337b2fed3 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.vespa.hosted.controller.restapi.horizon;

import com.yahoo.component.annotation.Inject;
import com.yahoo.config.provision.SystemName;
import com.yahoo.config.provision.TenantName;
import com.yahoo.container.jdisc.HttpRequest;
import com.yahoo.container.jdisc.HttpResponse;
import com.yahoo.container.jdisc.ThreadedHttpRequestHandler;
import com.yahoo.restapi.ErrorResponse;
import com.yahoo.restapi.Path;
import com.yahoo.vespa.flags.BooleanFlag;
import com.yahoo.vespa.flags.FetchVector;
import com.yahoo.vespa.flags.FlagSource;
import com.yahoo.vespa.flags.Flags;
import com.yahoo.vespa.hosted.controller.Controller;
import com.yahoo.vespa.hosted.controller.api.integration.horizon.HorizonClient;
import com.yahoo.vespa.hosted.controller.api.integration.horizon.HorizonResponse;
import com.yahoo.vespa.hosted.controller.api.role.Role;
import com.yahoo.vespa.hosted.controller.api.role.RoleDefinition;
import com.yahoo.vespa.hosted.controller.api.role.SecurityContext;
import com.yahoo.vespa.hosted.controller.api.role.TenantRole;
import com.yahoo.vespa.hosted.controller.restapi.ErrorResponses;
import com.yahoo.yolean.Exceptions;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.EnumSet;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * Proxies metrics requests from Horizon UI
 *
 * @author valerijf
 */
public class HorizonApiHandler extends ThreadedHttpRequestHandler {

    private final SystemName systemName;
    private final HorizonClient client;
    private final BooleanFlag enabledHorizonDashboard;

    private static final EnumSet<RoleDefinition> operatorRoleDefinitions =
            EnumSet.of(RoleDefinition.hostedOperator, RoleDefinition.hostedSupporter);

    @Inject
    public HorizonApiHandler(ThreadedHttpRequestHandler.Context parentCtx, Controller controller, FlagSource flagSource) {
        super(parentCtx);
        this.systemName = controller.system();
        this.client = controller.serviceRegistry().horizonClient();
        this.enabledHorizonDashboard = Flags.ENABLED_HORIZON_DASHBOARD.bindTo(flagSource);
    }

    @Override
    public HttpResponse handle(HttpRequest request) {
        var roles = getRoles(request);
        var operator = roles.stream().map(Role::definition).anyMatch(operatorRoleDefinitions::contains);
        var authorizedTenants = getAuthorizedTenants(roles);

        if (!operator && authorizedTenants.isEmpty())
            return ErrorResponse.forbidden("No tenant with enabled metrics view");

        try {
            return switch (request.getMethod()) {
                case GET -> get(request);
                case POST -> post(request, authorizedTenants, operator);
                case PUT -> put(request);
                default -> ErrorResponse.methodNotAllowed("Method '" + request.getMethod() + "' is not supported");
            };
        }
        catch (IllegalArgumentException e) {
            return ErrorResponse.badRequest(Exceptions.toMessageString(e));
        }
        catch (RuntimeException e) {
            return ErrorResponses.logThrowing(request, log, e);
        }
    }

    private HttpResponse get(HttpRequest request) {
        Path path = new Path(request.getUri());
        if (path.matches("/horizon/v1/config/dashboard/topFolders")) return new JsonInputStreamResponse(client.getTopFolders());
        if (path.matches("/horizon/v1/config/dashboard/file/{id}")) return getDashboard(path.get("id"));
        return ErrorResponse.notFoundError("Nothing at " + path);
    }

    private HttpResponse post(HttpRequest request, Set<TenantName> authorizedTenants, boolean operator) {
        Path path = new Path(request.getUri());
        if (path.matches("/horizon/v1/tsdb/api/query/graph")) return metricQuery(request, authorizedTenants, operator);
        if (path.matches("/horizon/v1/meta/search/timeseries")) return metaQuery(request, authorizedTenants, operator);
        return ErrorResponse.notFoundError("Nothing at " + path);
    }

    private HttpResponse put(HttpRequest request) {
        Path path = new Path(request.getUri());
        if (path.matches("/horizon/v1/config/user")) return new JsonInputStreamResponse(client.getUser());
        return ErrorResponse.notFoundError("Nothing at " + path);
    }

    private HttpResponse metricQuery(HttpRequest request, Set<TenantName> authorizedTenants, boolean operator) {
        try {
            byte[] data = TsdbQueryRewriter.rewrite(request.getData().readAllBytes(), authorizedTenants, operator, systemName);
            return new JsonInputStreamResponse(client.getMetrics(data));
        } catch (TsdbQueryRewriter.UnauthorizedException e) {
            return ErrorResponse.forbidden("Access denied");
        } catch (IOException e) {
            return ErrorResponse.badRequest("Failed to parse request body: " + e.getMessage());
        }
    }

    private HttpResponse metaQuery(HttpRequest request, Set<TenantName> authorizedTenants, boolean operator) {
        try {
            byte[] data = TsdbQueryRewriter.rewrite(request.getData().readAllBytes(), authorizedTenants, operator, systemName);
            return new JsonInputStreamResponse(client.getMetaData(data));
        } catch (TsdbQueryRewriter.UnauthorizedException e) {
            return ErrorResponse.forbidden("Access denied");
        } catch (IOException e) {
            return ErrorResponse.badRequest("Failed to parse request body: " + e.getMessage());
        }
    }

    private HttpResponse getDashboard(String id) {
        try {
            int dashboardId = Integer.parseInt(id);
            return new JsonInputStreamResponse(client.getDashboard(dashboardId));
        } catch (NumberFormatException e) {
            return ErrorResponse.badRequest("Dashboard ID must be integer, was " + id);
        }
    }

    private static Set<Role> getRoles(HttpRequest request) {
        return Optional.ofNullable(request.getJDiscRequest().context().get(SecurityContext.ATTRIBUTE_NAME))
                .filter(SecurityContext.class::isInstance)
                .map(SecurityContext.class::cast)
                .map(SecurityContext::roles)
                .orElseThrow(() -> new IllegalArgumentException("Attribute '" + SecurityContext.ATTRIBUTE_NAME + "' was not set on request"));
    }

    private Set<TenantName> getAuthorizedTenants(Set<Role> roles) {
        return roles.stream()
                .filter(TenantRole.class::isInstance)
                .map(role -> ((TenantRole) role).tenant())
                .filter(tenant -> enabledHorizonDashboard.with(FetchVector.Dimension.TENANT_ID, tenant.value()).value())
                .collect(Collectors.toSet());
    }

    private static class JsonInputStreamResponse extends HttpResponse {

        private final HorizonResponse response;

        public JsonInputStreamResponse(HorizonResponse response) {
            super(response.code());
            this.response = response;
        }

        @Override
        public String getContentType() {
            return "application/json";
        }

        @Override
        public void render(OutputStream outputStream) throws IOException {
            try (InputStream inputStream = response.inputStream()) {
                inputStream.transferTo(outputStream);
            }
        }
    }
}