Parcourir la source

show list of addons for preview targets (#4111)

ianedwards il y a 2 ans
Parent
commit
4393639ec4

+ 106 - 0
api/server/handlers/addons/list.go

@@ -0,0 +1,106 @@
+package addons
+
+import (
+	"encoding/base64"
+	"net/http"
+
+	"connectrpc.com/connect"
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// LatestAddonsHandler handles requests to the /addons/latest endpoint
+type LatestAddonsHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewLatestAddonsHandler returns a new LatestAddonsHandler
+func NewLatestAddonsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *LatestAddonsHandler {
+	return &LatestAddonsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// LatestAddonsRequest represents the request for the /addons/latest endpoint
+type LatestAddonsRequest struct {
+	DeploymentTargetID string `schema:"deployment_target_id"`
+}
+
+// LatestAddonsResponse represents the response from the /addons/latest endpoint
+type LatestAddonsResponse struct {
+	Base64Addons []string `json:"base64_addons"`
+}
+
+func (c *LatestAddonsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-addons")
+	defer span.End()
+
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &LatestAddonsRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID},
+	)
+
+	var deploymentTargetIdentifier *porterv1.DeploymentTargetIdentifier
+	if request.DeploymentTargetID != "" {
+		deploymentTargetIdentifier = &porterv1.DeploymentTargetIdentifier{
+			Id: request.DeploymentTargetID,
+		}
+	}
+
+	latestAddonsReq := connect.NewRequest(&porterv1.LatestAddonsRequest{
+		ProjectId:                  int64(project.ID),
+		ClusterId:                  int64(cluster.ID),
+		DeploymentTargetIdentifier: deploymentTargetIdentifier,
+	})
+
+	latestAddonsResp, err := c.Config().ClusterControlPlaneClient.LatestAddons(ctx, latestAddonsReq)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting latest addons")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if latestAddonsResp == nil || latestAddonsResp.Msg == nil {
+		err = telemetry.Error(ctx, span, nil, "latest addons response is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	res := &LatestAddonsResponse{
+		Base64Addons: []string{},
+	}
+
+	for _, addon := range latestAddonsResp.Msg.Addons {
+		by, err := helpers.MarshalContractObject(ctx, addon)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error marshaling addon")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		encoded := base64.StdEncoding.EncodeToString(by)
+		res.Base64Addons = append(res.Base64Addons, encoded)
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 90 - 0
api/server/router/addons.go

@@ -0,0 +1,90 @@
+package router
+
+import (
+	"fmt"
+
+	"github.com/go-chi/chi/v5"
+	"github.com/porter-dev/porter/api/server/handlers/addons"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
+	"github.com/porter-dev/porter/api/types"
+)
+
+// NewAddonScopedRegisterer returns the router for addon-scoped requests
+func NewAddonScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetAddonScopedRoutes,
+		Children:  children,
+	}
+}
+
+// GetAddonScopedRoutes returns the router for addon-scoped requests
+func GetAddonScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getAddonRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+func getAddonRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	relPath := "/addons"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	var routes []*router.Route
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/addons/latest -> addons.LatestAddonsHandler
+	latestAddonsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/latest", relPath),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	latestAddonsHandler := addons.NewLatestAddonsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: latestAddonsEndpoint,
+		Handler:  latestAddonsHandler,
+		Router:   r,
+	})
+
+	return routes, newPath
+}

+ 2 - 1
api/server/router/router.go

@@ -34,8 +34,9 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 	cloudProviderRegisterer := NewCloudProviderScopedRegisterer(datastoreRegisterer)
 	clusterIntegrationRegisterer := NewClusterIntegrationScopedRegisterer()
 	stackRegisterer := NewPorterAppScopedRegisterer()
+	addonRegisterer := NewAddonScopedRegisterer()
 	deploymentTargetRegisterer := NewDeploymentTargetScopedRegisterer()
-	clusterRegisterer := NewClusterScopedRegisterer(namespaceRegisterer, clusterIntegrationRegisterer, stackRegisterer, deploymentTargetRegisterer)
+	clusterRegisterer := NewClusterScopedRegisterer(namespaceRegisterer, clusterIntegrationRegisterer, stackRegisterer, deploymentTargetRegisterer, addonRegisterer)
 	infraRegisterer := NewInfraScopedRegisterer()
 	gitInstallationRegisterer := NewGitInstallationScopedRegisterer()
 	registryRegisterer := NewRegistryScopedRegisterer()

+ 7 - 7
dashboard/src/lib/addons/index.ts

@@ -64,15 +64,15 @@ export function clientAddonToProto(addon: ClientAddon): Addon {
   return proto;
 }
 
-export function clientAddonFromProto(args: {
+export function clientAddonFromProto({
+  addon,
+  variables = {},
+  secrets = {},
+}: {
   addon: Addon;
-  variables: Record<string, string>;
-  secrets: Record<string, string>;
+  variables?: Record<string, string>;
+  secrets?: Record<string, string>;
 }): ClientAddon {
-  const addon = args.addon;
-  const variables = args.variables;
-  const secrets = args.secrets;
-
   if (!addon.config.case) {
     throw new Error("Addon type is unspecified");
   }

+ 118 - 0
dashboard/src/main/home/app-dashboard/apps/Addon.tsx

@@ -0,0 +1,118 @@
+import React, { useMemo } from "react";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
+import CopyToClipboard from "components/CopyToClipboard";
+import Container from "components/porter/Container";
+import Icon from "components/porter/Icon";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { type ClientAddon } from "lib/addons";
+
+import { useDeploymentTarget } from "shared/DeploymentTargetContext";
+import copy from "assets/copy-left.svg";
+import postgresql from "assets/postgresql.svg";
+
+import { Block, Row } from "./AppGrid";
+
+type AddonProps = {
+  addon: ClientAddon;
+  view: "grid" | "list";
+};
+
+export const Addon: React.FC<AddonProps> = ({ addon, view }) => {
+  const { currentDeploymentTarget } = useDeploymentTarget();
+
+  const endpoint = useMemo(() => {
+    if (!currentDeploymentTarget) return "";
+    if (!addon.name.value) return "";
+
+    return `${addon.name.value}-postgres.${currentDeploymentTarget.namespace}.svc.cluster.local:5432`;
+  }, [currentDeploymentTarget, addon.name.value]);
+
+  return match(view)
+    .with("grid", () => (
+      <Block locked>
+        <Container row>
+          <Spacer inline width="1px" />
+          <Icon height="16px" src={postgresql} />
+          <Spacer inline width="12px" />
+          <Text size={14}>{addon.name.value}</Text>
+          <Spacer inline x={2} />
+        </Container>
+        <div>
+          <Text color="helper">Endpoint</Text>
+          <Spacer y={0.1} />
+          <IdContainer>
+            <Text size={10} truncate>
+              <Code>{endpoint}</Code>
+            </Text>
+            <CopyContainer>
+              <CopyToClipboard text={endpoint}>
+                <CopyIcon src={copy} alt="copy" />
+              </CopyToClipboard>
+            </CopyContainer>
+          </IdContainer>
+        </div>
+      </Block>
+    ))
+    .with("list", () => (
+      <Row locked>
+        <Container row>
+          <Spacer inline width="1px" />
+          <Icon height="16px" src={postgresql} />
+          <Spacer inline width="12px" />
+          <Text size={14}>{addon.name.value}</Text>
+          <Spacer inline x={1} />
+        </Container>
+        <Spacer height="15px" />
+        <Text color="helper">Endpoint</Text>
+        <Spacer y={0.1} />
+        <IdContainer>
+          <Text size={10} truncate>
+            <Code>{endpoint}</Code>
+          </Text>
+          <CopyContainer>
+            <CopyToClipboard text={endpoint}>
+              <CopyIcon src={copy} alt="copy" />
+            </CopyToClipboard>
+          </CopyContainer>
+        </IdContainer>
+      </Row>
+    ))
+    .exhaustive();
+};
+
+const Code = styled.span`
+  font-family: monospace;
+`;
+
+const IdContainer = styled.div`
+  background: #26292e;
+  border-radius: 5px;
+  padding: 10px;
+  display: flex;
+  width: 100%px;
+  border-radius: 5px;
+  border: 1px solid ${({ theme }) => theme.border};
+  align-items: center;
+  user-select: text;
+  text-overflow: ellipsis;
+`;
+
+const CopyContainer = styled.div`
+  display: flex;
+  align-items: center;
+  margin-left: auto;
+`;
+
+const CopyIcon = styled.img`
+  cursor: pointer;
+  margin-left: 5px;
+  margin-right: 5px;
+  width: 15px;
+  height: 15px;
+  :hover {
+    opacity: 0.8;
+  }
+`;

+ 26 - 8
dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx

@@ -9,6 +9,7 @@ import Container from "components/porter/Container";
 import Fieldset from "components/porter/Fieldset";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
+import { type ClientAddon } from "lib/addons";
 
 import { useDeploymentTarget } from "shared/DeploymentTargetContext";
 import { search } from "shared/search";
@@ -18,17 +19,25 @@ import target from "assets/target.svg";
 import time from "assets/time.png";
 
 import { Context } from "../../../../shared/Context";
+import { Addon } from "./Addon";
 import { AppIcon, AppSource } from "./AppMeta";
 import { type AppRevisionWithSource } from "./types";
 
 type AppGridProps = {
   apps: AppRevisionWithSource[];
+  addons: ClientAddon[];
   searchValue: string;
   view: "grid" | "list";
   sort: "letter" | "calendar";
 };
 
-const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
+const AppGrid: React.FC<AppGridProps> = ({
+  apps,
+  addons,
+  searchValue,
+  view,
+  sort,
+}) => {
   const { currentDeploymentTarget } = useDeploymentTarget();
   const { currentProject } = useContext(Context);
 
@@ -142,6 +151,9 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
             );
           }
         )}
+        {addons.map((a) => {
+          return <Addon addon={a} view={view} key={a.name.value} />;
+        })}
       </GridList>
     ))
     .with("list", () => (
@@ -184,6 +196,9 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
             );
           }
         )}
+        {addons.map((a) => {
+          return <Addon addon={a} view={view} key={a.name.value} />;
+        })}
       </List>
     ))
     .exhaustive();
@@ -204,20 +219,22 @@ const GridList = styled.div`
   grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
 `;
 
-const Block = styled.div`
+export const Block = styled.div<{ locked?: boolean }>`
   height: 150px;
   flex-direction: column;
   display: flex;
   justify-content: space-between;
-  cursor: pointer;
+  cursor: ${(props) => (props.locked ? "default" : "pointer")};
   padding: 20px;
   color: ${(props) => props.theme.text.primary};
   position: relative;
   border-radius: 5px;
-  background: ${(props) => props.theme.clickable.bg};
+  background: ${(props) =>
+    props.locked ? props.theme.fg : props.theme.clickable.bg};
   border: 1px solid #494b4f;
+
   :hover {
-    border: 1px solid #7a7b80;
+    border: ${(props) => (props.locked ? "" : `1px solid #7a7b80`)};
   }
   animation: fadeIn 0.3s 0s;
   @keyframes fadeIn {
@@ -234,12 +251,13 @@ const List = styled.div`
   overflow: hidden;
 `;
 
-const Row = styled.div<{ isAtBottom?: boolean }>`
-  cursor: pointer;
+export const Row = styled.div<{ isAtBottom?: boolean; locked?: boolean }>`
+  cursor: ${(props) => (props.locked ? "default" : "pointer")};
   padding: 15px;
   border-bottom: ${(props) =>
     props.isAtBottom ? "none" : "1px solid #494b4f"};
-  background: ${(props) => props.theme.clickable.bg};
+  background: ${(props) =>
+    props.locked ? props.theme.fg : props.theme.clickable.bg};
   position: relative;
   border: 1px solid #494b4f;
   border-radius: 5px;

+ 110 - 43
dashboard/src/main/home/app-dashboard/apps/Apps.tsx

@@ -1,5 +1,6 @@
-import React, { useCallback, useContext, useState } from "react";
-import { useQuery } from "@tanstack/react-query";
+import React, { useCallback, useContext, useMemo, useState } from "react";
+import { Addon } from "@porter-dev/api-contracts/src/porter/v1/addons_pb";
+import { useQueries } from "@tanstack/react-query";
 import { useHistory } from "react-router";
 import styled from "styled-components";
 import { z } from "zod";
@@ -16,6 +17,7 @@ import Text from "components/porter/Text";
 import Toggle from "components/porter/Toggle";
 import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
 import DeleteEnvModal from "main/home/cluster-dashboard/preview-environments/v2/DeleteEnvModal";
+import { clientAddonFromProto, type ClientAddon } from "lib/addons";
 import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
 
 import api from "shared/api";
@@ -31,6 +33,12 @@ import web from "assets/web.png";
 import AppGrid from "./AppGrid";
 import { appRevisionWithSourceValidator } from "./types";
 
+export type ClientAddonWithEnv = {
+  addon: ClientAddon;
+  variables: Record<string, string>;
+  secrets: Record<string, string>;
+};
+
 const Apps: React.FC = () => {
   const { currentProject, currentCluster } = useContext(Context);
   const { updateAppStep } = useAppAnalytics();
@@ -43,53 +51,111 @@ const Apps: React.FC = () => {
   const [showDeleteEnvModal, setShowDeleteEnvModal] = useState(false);
   const [envDeleting, setEnvDeleting] = useState(false);
 
-  const { data: apps = [], status } = useQuery(
-    [
-      "getLatestAppRevisions",
+  const [{ data: apps = [], status }, { data: addons = [] }] = useQueries({
+    queries: [
       {
-        cluster_id: currentCluster?.id,
-        project_id: currentProject?.id,
-        deployment_target_id: currentDeploymentTarget?.id,
+        queryKey: [
+          "getLatestAppRevisions",
+          {
+            cluster_id: currentCluster?.id,
+            project_id: currentProject?.id,
+            deployment_target_id: currentDeploymentTarget?.id,
+          },
+        ],
+        queryFn: async () => {
+          if (
+            !currentCluster ||
+            !currentProject ||
+            currentCluster.id === -1 ||
+            currentProject.id === -1 ||
+            !currentDeploymentTarget
+          ) {
+            return;
+          }
+
+          const res = await api.getLatestAppRevisions(
+            "<token>",
+            {
+              deployment_target_id:
+                currentProject.managed_deployment_targets_enabled &&
+                !currentDeploymentTarget.is_preview
+                  ? undefined
+                  : currentDeploymentTarget.id,
+              ignore_preview_apps: !currentDeploymentTarget.is_preview,
+            },
+            { cluster_id: currentCluster.id, project_id: currentProject.id }
+          );
+
+          const apps = await z
+            .object({
+              app_revisions: z.array(appRevisionWithSourceValidator),
+            })
+            .parseAsync(res.data);
+
+          return apps.app_revisions;
+        },
+        enabled:
+          !!currentCluster && !!currentProject && !!currentDeploymentTarget,
+        refetchInterval: 5000,
+        refetchOnWindowFocus: false,
       },
-    ],
-    async () => {
-      if (
-        !currentCluster ||
-        !currentProject ||
-        currentCluster.id === -1 ||
-        currentProject.id === -1 ||
-        !currentDeploymentTarget
-      ) {
-        return;
-      }
+      {
+        queryKey: [
+          "listLatestAddons",
+          {
+            cluster_id: currentCluster?.id,
+            project_id: currentProject?.id,
+            deployment_target_id: currentDeploymentTarget?.id,
+          },
+        ],
+        queryFn: async () => {
+          if (
+            !currentCluster ||
+            !currentProject ||
+            currentCluster.id === -1 ||
+            currentProject.id === -1 ||
+            !currentDeploymentTarget
+          ) {
+            return;
+          }
 
-      const res = await api.getLatestAppRevisions(
-        "<token>",
-        {
-          deployment_target_id:
-            currentProject.managed_deployment_targets_enabled &&
-            !currentDeploymentTarget.is_preview
-              ? undefined
-              : currentDeploymentTarget.id,
-          ignore_preview_apps: !currentDeploymentTarget.is_preview,
+          const res = await api.listLatestAddons(
+            "<token>",
+            {
+              deployment_target_id: currentDeploymentTarget.id,
+            },
+            { clusterId: currentCluster.id, projectId: currentProject.id }
+          );
+
+          const parsed = await z
+            .object({
+              base64_addons: z.array(z.string()),
+            })
+            .parseAsync(res.data);
+
+          return parsed.base64_addons;
         },
-        { cluster_id: currentCluster.id, project_id: currentProject.id }
-      );
+        enabled:
+          !!currentCluster &&
+          !!currentProject &&
+          !!currentDeploymentTarget &&
+          currentDeploymentTarget.is_preview,
+        refetchOnWindowFocus: false,
+      },
+    ],
+  });
 
-      const apps = await z
-        .object({
-          app_revisions: z.array(appRevisionWithSourceValidator),
-        })
-        .parseAsync(res.data);
+  const clientAddons: ClientAddon[] = useMemo(() => {
+    return addons.map((a) => {
+      const proto = Addon.fromJsonString(atob(a), {
+        ignoreUnknownFields: true,
+      });
 
-      return apps.app_revisions;
-    },
-    {
-      refetchOnWindowFocus: false,
-      enabled:
-        !!currentCluster && !!currentProject && !!currentDeploymentTarget,
-    }
-  );
+      return clientAddonFromProto({
+        addon: proto,
+      });
+    });
+  }, [addons]);
 
   const deletePreviewEnv = useCallback(async () => {
     try {
@@ -256,6 +322,7 @@ const Apps: React.FC = () => {
         <Spacer y={1} />
         <AppGrid
           apps={apps}
+          addons={clientAddons}
           sort={sort}
           view={view}
           searchValue={searchValue}

+ 12 - 0
dashboard/src/shared/api.tsx

@@ -1245,6 +1245,17 @@ const getAppTemplate = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/templates`;
 });
 
+const listLatestAddons = baseApi<
+{
+  deployment_target_id?: string;
+},
+{
+  projectId: number;
+  clusterId: number;
+}>("GET", ({ projectId, clusterId }) => {
+  return `/api/projects/${projectId}/clusters/${clusterId}/addons/latest`;
+})
+
 const getGitlabProcfileContents = baseApi<
   {
     path: string;
@@ -3447,6 +3458,7 @@ export default {
   createDeploymentTarget,
   getDeploymentTarget,
   getAppTemplate,
+  listLatestAddons,
   getGitlabProcfileContents,
   getProjectClusters,
   getProjectRegistries,

+ 2 - 0
go.work.sum

@@ -845,6 +845,8 @@ github.com/porter-dev/api-contracts v0.2.68 h1:OeU3RQAI6IpGC99UdDalrlRnNn7nevoxj
 github.com/porter-dev/api-contracts v0.2.68/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/api-contracts v0.2.73 h1:hsFcJSf0HLxS7VgV36qn5X3tYPzWG48mCvHwuOlU2eE=
 github.com/porter-dev/api-contracts v0.2.73/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.78 h1:Iyp1DL33mPxJZQSjH8W/ylv5Ch8i30eJJx9mvhZmhTU=
+github.com/porter-dev/api-contracts v0.2.78/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=