Explorar el Código

switch Azure provisioning to new preflight endpoint (#4326)

d-g-town hace 2 años
padre
commit
dab8157fa3

+ 1 - 1
.github/golangci-lint.yaml

@@ -64,4 +64,4 @@ output:
   path-prefix: ""
 
   # sorts results by: filepath, line and column
-  sort-results: false
+  sort-results: false

+ 44 - 1
api/server/handlers/project_integration/preflight_check.go

@@ -1,6 +1,7 @@
 package project_integration
 
 import (
+	"fmt"
 	"net/http"
 
 	"connectrpc.com/connect"
@@ -57,7 +58,7 @@ var recognizedPreflightCheckKeys = []string{
 	"apiEnabled",
 	"cidrAvailability",
 	"iamPermissions",
-	"resourceProviders",
+	"authz",
 }
 
 func (p *CreatePreflightCheckHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -91,6 +92,48 @@ func (p *CreatePreflightCheckHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		}
 	}
 
+	if cloudValues.Contract != nil && cloudValues.Contract.Cluster != nil && cloudValues.Contract.Cluster.CloudProvider == porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_AZURE {
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "new-endpoint", Value: true})
+		checkResp, err := p.Config().ClusterControlPlaneClient.CloudContractPreflightCheck(ctx,
+			connect.NewRequest(
+				&porterv1.CloudContractPreflightCheckRequest{
+					Contract: cloudValues.Contract,
+				},
+			),
+		)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error calling preflight checks")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		if checkResp.Msg == nil {
+			err = telemetry.Error(ctx, span, nil, "no message received from preflight checks")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		errors := []PreflightCheckError{}
+		for _, val := range checkResp.Msg.FailingPreflightChecks {
+			if val.Message == "" || !contains(recognizedPreflightCheckKeys, val.Type) {
+				continue
+			}
+
+			fmt.Printf("val: %+v\n", val.Metadata)
+
+			errors = append(errors, PreflightCheckError{
+				Name: val.Type,
+				Error: PorterError{
+					Message:  val.Message,
+					Metadata: val.Metadata,
+				},
+			})
+		}
+		resp.Errors = errors
+		p.WriteResult(w, r, resp)
+		return
+	}
+
 	checkResp, err := p.Config().ClusterControlPlaneClient.PreflightCheck(ctx, connect.NewRequest(&input))
 	if err != nil {
 		err = telemetry.Error(ctx, span, err, "error calling preflight checks")

+ 28 - 5
dashboard/src/components/porter/Error.tsx

@@ -3,9 +3,11 @@ import styled from "styled-components";
 
 import Modal from "./Modal";
 import Spacer from "./Spacer";
+import Text from "./Text";
 
 type Props = {
   message: string;
+  metadata?: Record<string, string>;
   ctaText?: string;
   ctaOnClick?: () => void;
   errorModalContents?: React.ReactNode;
@@ -14,6 +16,7 @@ type Props = {
 
 export const Error: React.FC<Props> = ({
   message,
+  metadata,
   ctaText,
   ctaOnClick,
   errorModalContents,
@@ -26,10 +29,18 @@ export const Error: React.FC<Props> = ({
       <StyledError maxWidth={maxWidth}>
         <i className="material-icons">error_outline</i>
         <Block>
-          <Text>Error: {message}</Text>
+          <Text color={"#ff385d"}>Error: {message}</Text>
           {ctaText && (errorModalContents != null || ctaOnClick != null) && (
             <>
               <Spacer y={0.5} />
+              {metadata &&
+                Object.entries(metadata).map(([key, value]) => (
+                  <div key={key}>
+                    <ErrorMessageLabel>{key}:</ErrorMessageLabel>
+                    <ErrorMessageContent>{value}</ErrorMessageContent>
+                  </div>
+                ))}
+              <Spacer y={0.5} />
               <Cta
                 onClick={() => {
                   errorModalContents ? setErrorModalOpen(true) : ctaOnClick?.();
@@ -57,10 +68,6 @@ export const Error: React.FC<Props> = ({
 
 export default Error;
 
-const Text = styled.span`
-  display: inline;
-`;
-
 const Block = styled.div`
   display: block;
 `;
@@ -101,3 +108,19 @@ const StyledError = styled.div<{ maxWidth?: string }>`
   }
   max-width: ${(props) => props.maxWidth || "100%"};
 `;
+
+const ErrorMessageLabel = styled.span`
+  font-weight: bold;
+  margin-left: 10px;
+  color: #9999aa;
+  user-select: text;
+`;
+const ErrorMessageContent = styled.div`
+  font-family: "Courier New", Courier, monospace;
+  padding: 5px 10px;
+  border-radius: 4px;
+  margin-left: 10px;
+  user-select: text;
+  cursor: text;
+  color: #9999aa;
+`;

+ 2 - 2
dashboard/src/components/porter/Link.tsx

@@ -59,9 +59,9 @@ const Underline = styled.div<{ color: string }>`
   background: ${(props) => props.color};
 `;
 
-const StyledLink = styled(DynamicLink) <{ hasunderline?: boolean, color: string }>`
+const StyledLink = styled(DynamicLink) <{ hasunderline?: boolean, color: string, removeInline?: boolean }>`
   color: ${(props) => props.color};
-  display: inline-flex;
+  ${(props) => !props.removeInline && "display: inline-flex;"};
   font-size: 13px;
   cursor: pointer;
   align-items: center;

+ 3 - 6
dashboard/src/components/porter/Step.tsx

@@ -1,5 +1,6 @@
 import React, { useEffect, useState } from "react";
 import styled from "styled-components";
+import Container from "./Container";
 
 type Props = {
   number: number;
@@ -13,19 +14,15 @@ const Step: React.FC<Props> = ({
   return (
     <StyledStep>
       <StepNumber>{number}</StepNumber>
-      <Block>
+      <Container>
         {children}
-      </Block>
+      </Container>
     </StyledStep>
   );
 };
 
 export default Step;
 
-const Block = styled.div`
-  display: block;
-`;
-
 const StepNumber = styled.div`
   height: 20px;
   min-width: 20px;

+ 92 - 5
dashboard/src/lib/clusters/constants.ts

@@ -1027,7 +1027,7 @@ const AWS_EIP_QUOTA_RESOLUTION: PreflightCheckResolution = {
     "You will need to either request more EIP addresses or delete existing ones in order to provision in the region specified. You can request more addresses by following these steps:",
   steps: [
     {
-      text: "Log into your AWS Account",
+      text: "Log in to your AWS Account",
       externalLink:
         "https://console.aws.amazon.com/billing/home?region=us-east-1#/account",
     },
@@ -1053,7 +1053,7 @@ const AWS_NAT_GATEWAY_QUOTA_RESOLUTION: PreflightCheckResolution = {
     "You will need to either request more NAT Gateways or delete existing ones in order to provision in the region specified. You can request more NAT Gateways by following these steps:",
   steps: [
     {
-      text: "Log into your AWS Account",
+      text: "Log in to your AWS Account",
       externalLink:
         "https://console.aws.amazon.com/billing/home?region=us-east-1#/account",
     },
@@ -1079,7 +1079,7 @@ const AWS_VPC_QUOTA_RESOLUTION: PreflightCheckResolution = {
     "You will need to either request more VPCs or delete existing ones in order to provision in the region specified. You can request more VPCs by following these steps:",
   steps: [
     {
-      text: "Log into your AWS Account",
+      text: "Log in to your AWS Account",
       externalLink:
         "https://console.aws.amazon.com/billing/home?region=us-east-1#/account",
     },
@@ -1105,7 +1105,7 @@ const AWS_VCPUS_QUOTA_RESOLUTION: PreflightCheckResolution = {
     "You will need to either request more vCPUs or delete existing instances in order to provision in the region specified. You can request more vCPUs by following these steps:",
   steps: [
     {
-      text: "Log into your AWS Account",
+      text: "Log in to your AWS Account",
       externalLink:
         "https://console.aws.amazon.com/billing/home?region=us-east-1#/account",
     },
@@ -1125,6 +1125,75 @@ const AWS_VCPUS_QUOTA_RESOLUTION: PreflightCheckResolution = {
     },
   ],
 };
+const AZURE_AUTHZ_RESOLUTION: PreflightCheckResolution = {
+  title: "Granting your service principal authorization to your subscription",
+  subtitle:
+    "You will need to authorize your service principal to read and write to your subscription. To properly configure the service principal, following the creation steps in our docs:",
+  steps: [
+    {
+      text: "Log in to your Azure Portal:",
+      externalLink:
+        "https://portal.azure.com",
+    },
+    {
+      text: "Click on the Azure Cloud Shell icon to the right of the global search bar, and select Bash as your shell"
+    },
+    {
+      text: "Follow the directions in our docs to create the Azure role required for provisioning with Porter and attach it to a service principal:",
+      externalLink:
+          "https://docs.porter.run/provision/provisioning-on-azure#creating-the-service-principal",
+    },
+    {
+      text: "Note the outputted credentials, return to the Azure credential input screen in Porter, and re-enter the credentials to provision",
+    },
+  ],
+};
+const AZURE_RESOURCE_PROVIDER_RESOLUTION: PreflightCheckResolution = {
+  title: "Enable required resource providers in your subscription",
+  subtitle:
+    "You will need to enable certain resource providers in your Azure subscription in order for Porter to provision your infrastructure:",
+  steps: [
+    {
+      text: "Take note of any particular resource providers flagged as missing in the provisioning error message."
+    },
+    {
+      text: "Log in to your Azure Portal:",
+      externalLink:
+          "https://portal.azure.com",
+    },
+    {
+      text: "Follow the directions in our docs to enable all required resource providers in your subscription:",
+      externalLink:
+          "https://docs.porter.run/provision/provisioning-on-azure#prerequisites",
+    },
+    {
+      text: "Changes may take a few minutes to take effect. Once you have enabled the resource providers, return to Porter and retry the provision.",
+    },
+  ],
+};
+const AZURE_VCPUS_QUOTA_RESOLUTION: PreflightCheckResolution = {
+  title: "Requesting more vCPUs",
+  subtitle:
+      "You will need to either request more vCPUs or delete existing instances in order to provision in the location specified. You can request more vCPUs by following these steps:",
+  steps: [
+    {
+      text: "Note which resource families were flagged in the provisioning error message. These may include your requested machine types, as well as those required by Porter."
+    },
+    {
+      text: "Log in to your Azure Portal:",
+      externalLink:
+          "https://portal.azure.com",
+    },
+    {
+      text: "Follow the directions in our docs to request quota increases:",
+      externalLink:
+          "https://docs.porter.run/provision/provisioning-on-azure#compute-quotas",
+    },
+    {
+      text: "Requests may take a few hours to be fulfilled. Once you have confirmed that the quota increases have been granted, return to Porter and retry the provision.",
+    },
+  ],
+};
 
 const SUPPORTED_AWS_PREFLIGHT_CHECKS: PreflightCheck[] = [
   {
@@ -1149,6 +1218,24 @@ const SUPPORTED_AWS_PREFLIGHT_CHECKS: PreflightCheck[] = [
   },
 ];
 
+const SUPPORTED_AZURE_PREFLIGHT_CHECKS: PreflightCheck[] = [
+  {
+    name: "authz",
+    displayName: "Subscription authorization",
+    resolution: AZURE_AUTHZ_RESOLUTION,
+  },
+  {
+    name: "apiEnabled",
+    displayName: "Enable resource providers",
+    resolution: AZURE_RESOURCE_PROVIDER_RESOLUTION,
+  },
+  {
+    name: "vcpu",
+    displayName: "vCPU availability",
+    resolution: AZURE_VCPUS_QUOTA_RESOLUTION,
+  },
+];
+
 const SUPPORTED_GCP_PREFLIGHT_CHECKS: PreflightCheck[] = [
   {
     name: "apiEnabled",
@@ -1254,7 +1341,7 @@ export const CloudProviderAzure: ClientCloudProvider & {
   machineTypes: SUPPORTED_AZURE_MACHINE_TYPES,
   baseCost: 164.69,
   newClusterDefaultContract: DEFAULT_AKS_CONTRACT,
-  preflightChecks: [],
+  preflightChecks: SUPPORTED_AZURE_PREFLIGHT_CHECKS,
   config: {
     kind: "Azure",
     skuTiers: SUPPORTED_AZURE_SKU_TIERS,

+ 3 - 1
dashboard/src/lib/clusters/types.ts

@@ -241,6 +241,7 @@ export type ClientMachineType = {
 type PreflightCheckResolutionStep = {
   text: string;
   externalLink?: string;
+  code?: string;
 };
 export type PreflightCheckResolution = {
   title: string;
@@ -508,6 +509,7 @@ const preflightCheckKeyValidator = z.enum([
   "apiEnabled",
   "cidrAvailability",
   "iamPermissions",
+  "authz",
 ]);
 type PreflightCheckKey = z.infer<typeof preflightCheckKeyValidator>;
 export const preflightCheckValidator = z.object({
@@ -519,7 +521,7 @@ export const preflightCheckValidator = z.object({
         metadata: z.record(z.string()).optional(),
       }),
     })
-    .array(),
+    .array()
 });
 export const createContractResponseValidator = z.object({
   contract_revision: z.object({

+ 4 - 3
dashboard/src/lib/hooks/useCluster.ts

@@ -10,9 +10,9 @@ import {
   updateExistingClusterContract,
 } from "lib/clusters";
 import {
-  CloudProviderAWS,
-  CloudProviderGCP,
-  SUPPORTED_CLOUD_PROVIDERS,
+    CloudProviderAWS, CloudProviderAzure,
+    CloudProviderGCP,
+    SUPPORTED_CLOUD_PROVIDERS,
 } from "lib/clusters/constants";
 import {
   clusterStateValidator,
@@ -382,6 +382,7 @@ export const useUpdateCluster = ({
         )
           .with("AWS", () => CloudProviderAWS.preflightChecks)
           .with("GCP", () => CloudProviderGCP.preflightChecks)
+          .with("Azure", () => CloudProviderAzure.preflightChecks)
           .otherwise(() => []);
 
         const clientPreflightChecks: ClientPreflightCheck[] = parsed.errors

+ 50 - 84
dashboard/src/main/home/infrastructure-dashboard/modals/PreflightChecksModal.tsx

@@ -1,9 +1,10 @@
-import React, { useState } from "react";
+import React from "react";
 import styled from "styled-components";
 import { match } from "ts-pattern";
 
 import Loading from "components/Loading";
 import { Error as ErrorComponent } from "components/porter/Error";
+import Expandable from "components/porter/Expandable";
 import Modal from "components/porter/Modal";
 import Spacer from "components/porter/Spacer";
 import StatusDot from "components/porter/StatusDot";
@@ -14,17 +15,15 @@ import ResolutionStepsModalContents from "./help/preflight/ResolutionStepsModalC
 
 type ItemProps = {
   preflightCheck: ClientPreflightCheck;
+  preExpanded?: boolean;
 };
-export const CheckItem: React.FC<ItemProps> = ({ preflightCheck }) => {
-  const [isExpanded, setIsExpanded] = useState(true);
-
-  return (
-    <CheckItemContainer>
-      <CheckItemTop
-        onClick={() => {
-          setIsExpanded(!isExpanded);
-        }}
-      >
+export const CheckItem: React.FC<ItemProps> = ({
+  preflightCheck,
+  preExpanded = true,
+}) => {
+  const renderHeader = (): React.ReactElement => {
+    return (
+      <CheckItemTop>
         {match(preflightCheck.status)
           .with("pending", () => (
             <Loading offset="0px" width="20px" height="20px" />
@@ -37,42 +36,39 @@ export const CheckItem: React.FC<ItemProps> = ({ preflightCheck }) => {
           )}
         <Spacer inline x={1} />
         <Text style={{ flex: 1 }}>{preflightCheck.title}</Text>
-        {preflightCheck.error && (
-          <ExpandIcon className="material-icons" isExpanded={isExpanded}>
-            arrow_drop_down
-          </ExpandIcon>
+        {preflightCheck?.error?.metadata?.quotaName && (
+          <Text color={"helper"}>{preflightCheck?.error?.metadata?.quotaName}</Text>
         )}
       </CheckItemTop>
-      {isExpanded && preflightCheck.error && (
-        <div>
-          <ErrorComponent
-            message={preflightCheck.error.detail}
-            ctaText={
-              preflightCheck.error.resolution
-                ? "Troubleshooting steps"
-                : undefined
-            }
-            errorModalContents={
-              preflightCheck.error.resolution ? (
-                <ResolutionStepsModalContents
-                  resolution={preflightCheck.error.resolution}
-                />
-              ) : undefined
-            }
-          />
-          <Spacer y={0.5} />
-          {preflightCheck.error.metadata &&
-            Object.entries(preflightCheck.error.metadata).map(
-              ([key, value]) => (
-                <div key={key}>
-                  <ErrorMessageLabel>{key}:</ErrorMessageLabel>
-                  <ErrorMessageContent>{value}</ErrorMessageContent>
-                </div>
-              )
-            )}
-        </div>
-      )}
-    </CheckItemContainer>
+    );
+  };
+
+  if (!preflightCheck.error) {
+    return renderHeader();
+  }
+
+  return (
+    <Expandable preExpanded={preExpanded} header={renderHeader()}>
+      <div>
+        <ErrorComponent
+          message={preflightCheck.error.detail}
+          ctaText={
+            preflightCheck.error.resolution
+              ? "Troubleshooting steps"
+              : undefined
+          }
+          metadata={preflightCheck.error.metadata}
+          errorModalContents={
+            preflightCheck.error.resolution ? (
+              <ResolutionStepsModalContents
+                resolution={preflightCheck.error.resolution}
+              />
+            ) : undefined
+          }
+        />
+        <Spacer y={0.5} />
+      </div>
+    </Expandable>
   );
 };
 
@@ -90,13 +86,17 @@ const PreflightChecksModal: React.FC<Props> = ({
         <Text size={16}>Cluster provision check</Text>
         <Spacer y={0.5} />
         <Text color="helper">
-          Your cloud provider account does not have enough resources to
-          provision this cluster. Please visit your cloud provider or change
-          your cluster configuration, then re-submit.
+          Your cloud provider account does not have the required permissions
+          and/or resources to provision with Porter. Please resolve the
+          following issues or change your cluster configuration and try again.
         </Text>
         <Spacer y={1} />
-        {preflightChecks.map((pfc) => (
-          <CheckItem preflightCheck={pfc} key={pfc.title} />
+        {preflightChecks.map((pfc, idx) => (
+          <CheckItem
+            preflightCheck={pfc}
+            key={pfc.title}
+            preExpanded={idx === 0}
+          />
         ))}
       </AppearingDiv>
     </Modal>
@@ -124,43 +124,9 @@ const AppearingDiv = styled.div`
   }
 `;
 
-const CheckItemContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  border: 1px solid ${(props) => props.theme.border};
-  border-radius: 5px;
-  font-size: 13px;
-  width: 100%;
-  margin-bottom: 10px;
-  padding-left: 10px;
-  cursor: pointer;
-  background: ${(props) => props.theme.clickable.bg};
-`;
-
 const CheckItemTop = styled.div`
   display: flex;
   align-items: center;
   padding: 10px;
   background: ${(props) => props.theme.clickable.bg};
 `;
-
-const ExpandIcon = styled.i<{ isExpanded: boolean }>`
-  margin-left: 8px;
-  color: #ffffff66;
-  font-size: 20px;
-  cursor: pointer;
-  border-radius: 20px;
-  transform: ${(props) => (props.isExpanded ? "" : "rotate(-90deg)")};
-`;
-const ErrorMessageLabel = styled.span`
-  font-weight: bold;
-  margin-left: 10px;
-`;
-const ErrorMessageContent = styled.div`
-  font-family: "Courier New", Courier, monospace;
-  padding: 5px 10px;
-  border-radius: 4px;
-  margin-left: 10px;
-  user-select: text;
-  cursor: text;
-`;

+ 2 - 2
dashboard/src/main/home/infrastructure-dashboard/modals/help/preflight/ResolutionStepsModalContents.tsx

@@ -11,7 +11,7 @@ import { type PreflightCheckResolution } from "lib/clusters/types";
 type Props = {
   resolution: PreflightCheckResolution;
 };
-const ElasticIPQuotaModalContents: React.FC<Props> = ({ resolution }) => {
+const ResolutionStepsModalContents: React.FC<Props> = ({ resolution }) => {
   return (
     <div>
       <Text size={16} weight={500}>
@@ -41,7 +41,7 @@ const ElasticIPQuotaModalContents: React.FC<Props> = ({ resolution }) => {
   );
 };
 
-export default ElasticIPQuotaModalContents;
+export default ResolutionStepsModalContents;
 
 const StepContainer = styled.div`
   display: flex;

+ 1 - 1
go.mod

@@ -83,7 +83,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.110
+	github.com/porter-dev/api-contracts v0.2.111
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 2
go.sum

@@ -1523,8 +1523,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.110 h1:/yfUCX4TtnynnqL4zUYMD+U96hkLDvgQqlrOuhZF7Ao=
-github.com/porter-dev/api-contracts v0.2.110/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.111 h1:MMDIMumereUdKIK2yNZjhlCRgNz6jBh+uK+Kmf0qbTc=
+github.com/porter-dev/api-contracts v0.2.111/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.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 1 - 0
go.work.sum

@@ -856,6 +856,7 @@ github.com/porter-dev/api-contracts v0.2.73/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4
 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/api-contracts v0.2.93/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.110/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
 github.com/pquerna/cachecontrol v0.1.0 h1:yJMy84ti9h/+OEWa752kBTKv4XC30OtVVHYv/8cTqKc=
 github.com/pquerna/cachecontrol v0.1.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI=