mirror of https://github.com/portainer/portainer
feat(libhelm): allow passing optional env and http client [EE-5252] (#8758)
parent
a7474188b9
commit
7a8a20e0cc
|
@ -25,7 +25,7 @@ type addHelmRepoUrlPayload struct {
|
|||
}
|
||||
|
||||
func (p *addHelmRepoUrlPayload) Validate(_ *http.Request) error {
|
||||
return libhelm.ValidateHelmRepositoryURL(p.URL)
|
||||
return libhelm.ValidateHelmRepositoryURL(p.URL, nil)
|
||||
}
|
||||
|
||||
// @id HelmUserRepositoryCreate
|
||||
|
|
|
@ -143,7 +143,7 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
|
|||
newHelmRepo := strings.TrimSuffix(strings.ToLower(*payload.HelmRepositoryURL), "/")
|
||||
|
||||
if newHelmRepo != settings.HelmRepositoryURL && newHelmRepo != portainer.DefaultHelmRepositoryURL {
|
||||
err := libhelm.ValidateHelmRepositoryURL(*payload.HelmRepositoryURL)
|
||||
err := libhelm.ValidateHelmRepositoryURL(*payload.HelmRepositoryURL, nil)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid Helm repository URL. Must correspond to a valid URL format", err)
|
||||
}
|
||||
|
|
|
@ -11,7 +11,10 @@
|
|||
<div class="actionBar">
|
||||
<form class="form-horizontal" name="addUserHelmRepoForm">
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small"> Add a Helm repository. All Helm charts in the repository will be added to the list. </span>
|
||||
<span class="col-sm-12 text-muted small">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
Add a Helm repository. All Helm charts in the repository will be added to the list.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-2">
|
||||
|
|
|
@ -13,7 +13,7 @@ function SSLServiceFactory(SSL) {
|
|||
return SSL.get().$promise;
|
||||
}
|
||||
|
||||
function upload(httpEnabled, cert, key) {
|
||||
return SSL.upload({ httpEnabled, cert, key }).$promise;
|
||||
function upload(SSLPayload) {
|
||||
return SSL.upload(SSLPayload).$promise;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import { sslCertificate } from './ssl-certificate';
|
||||
import { sslCaFileSettings } from './ssl-ca-file-settings';
|
||||
|
||||
export default angular.module('portainer.settings.general', []).component('sslCertificateSettings', sslCertificate).name;
|
||||
export default angular.module('portainer.settings.general', []).component('sslCertificateSettings', sslCertificate).component('sslCaFileSettings', sslCaFileSettings).name;
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
import controller from './ssl-ca-file-settings-controller.js';
|
||||
|
||||
export const sslCaFileSettings = {
|
||||
templateUrl: './ssl-ca-file-settings.html',
|
||||
controller,
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
class SslCaFileSettingsController {
|
||||
/* @ngInject */
|
||||
constructor() {
|
||||
this.limitedFeature = FeatureId.CA_FILE;
|
||||
}
|
||||
}
|
||||
|
||||
export default SslCaFileSettingsController;
|
|
@ -0,0 +1,43 @@
|
|||
<div class="be-indicator-container limited-be">
|
||||
<div class="overlay">
|
||||
<div class="limited-be-link vertical-center"
|
||||
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
|
||||
><portainer-tooltip message="'This feature is currently limited to Business Edition users only. '"></portainer-tooltip
|
||||
></div>
|
||||
<div class="limited-be-content">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="key" title-text="Certificate Authority file for Kubernetes Helm repositories"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" name="$ctrl.sslForm">
|
||||
<span class="small text-muted vertical-center mb-3">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
Provide an additional CA file containing certificate(s) for HTTPS connections to Helm repositories.
|
||||
</span>
|
||||
|
||||
<!-- SSL Cert -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 flex items-center">
|
||||
<span class="space-right control-label col-sm-3 col-lg-2 !p-0 text-left">
|
||||
CA File
|
||||
<portainer-tooltip message="'Select a CA file containing your X.509 certificate(s), commonly a crt, cer or pem file.'"></portainer-tooltip>
|
||||
</span>
|
||||
<button class="btn btn-sm btn-primary !ml-0"> Select a file </button>
|
||||
<span class="ml-1 flex h-full items-center">
|
||||
<pr-icon icon="'x-circle'" class-name="'icon-danger'"></pr-icon>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm !ml-0">
|
||||
<span>Apply changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -21,9 +21,8 @@ class SslCertificateController {
|
|||
reloadingPage: false,
|
||||
};
|
||||
|
||||
const pemPattern = '.pem';
|
||||
this.certFilePattern = `${pemPattern},.crt,.cer,.cert`;
|
||||
this.keyFilePattern = `${pemPattern},.key`;
|
||||
this.certFilePattern = `.pem,.crt,.cer,.cert`;
|
||||
this.keyFilePattern = `.pem,.key`;
|
||||
|
||||
this.save = this.save.bind(this);
|
||||
this.onChangeForceHTTPS = this.onChangeForceHTTPS.bind(this);
|
||||
|
@ -46,7 +45,7 @@ class SslCertificateController {
|
|||
const cert = this.formValues.certFile ? await this.formValues.certFile.text() : null;
|
||||
const key = this.formValues.keyFile ? await this.formValues.keyFile.text() : null;
|
||||
const httpEnabled = !this.formValues.forceHTTPS;
|
||||
await this.SSLService.upload(httpEnabled, cert, key);
|
||||
await this.SSLService.upload({ httpEnabled, cert, key });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
location.reload();
|
||||
|
|
|
@ -16,22 +16,33 @@
|
|||
label="'Force HTTPS only'"
|
||||
on-change="($ctrl.onChangeForceHTTPS)"
|
||||
field-class="'col-sm-12'"
|
||||
label-class="'col-sm-2'"
|
||||
label-class="'col-sm-3 col-lg-2'"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 control-label"> Provide a new SSL Certificate to replace the existing one that is used for HTTPS connections. </span>
|
||||
</div>
|
||||
<span class="small text-muted vertical-center my-3">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
Provide a new SSL Certificate to replace the existing one that is used for HTTPS connections.
|
||||
</span>
|
||||
|
||||
<!-- SSL Cert -->
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12"> Upload a X.509 certificate, commonly a crt, a cer, or a pem file. </span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button class="btn btn-sm btn-primary" ngf-select ng-model="$ctrl.formValues.certFile" ngf-pattern="$ctrl.certFilePattern" name="certFile"> Select file </button>
|
||||
<span style="margin-left: 5px">
|
||||
<div class="col-sm-12 flex items-center">
|
||||
<span class="space-right control-label col-sm-3 col-lg-2 !p-0 text-left">
|
||||
SSL/TLS certificate
|
||||
<portainer-tooltip message="'Select an X.509 certificate file, commonly a crt, cer or pem file.'"></portainer-tooltip>
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-sm btn-primary !ml-0"
|
||||
ngf-select
|
||||
ng-model="$ctrl.formValues.certFile"
|
||||
ngf-pattern="$ctrl.certFilePattern"
|
||||
name="certFile"
|
||||
ngf-accept="$ctrl.certFilePattern"
|
||||
>
|
||||
Select a file
|
||||
</button>
|
||||
<span class="ml-1 flex h-full items-center">
|
||||
{{ $ctrl.formValues.certFile.name }}
|
||||
<pr-icon icon="'x-circle'" class-name="'icon-danger'" ng-if="!$ctrl.formValues.certFile"></pr-icon>
|
||||
</span>
|
||||
|
@ -48,14 +59,24 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSL Key -->
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12"> Upload a private key, commonly a key, or a pem file. </span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button class="btn btn-sm btn-primary" ngf-select ng-model="$ctrl.formValues.keyFile" ngf-pattern="$ctrl.keyFilePattern" name="keyFile"> Select file </button>
|
||||
<span style="margin-left: 5px">
|
||||
<div class="col-sm-12 flex items-center">
|
||||
<span class="space-right control-label col-sm-3 col-lg-2 !p-0 text-left">
|
||||
SSL/TLS private key
|
||||
<portainer-tooltip message="'Select a private key file, commonly a key, or pem file.'"></portainer-tooltip>
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-sm btn-primary !ml-0"
|
||||
ngf-select
|
||||
ng-model="$ctrl.formValues.keyFile"
|
||||
ngf-pattern="$ctrl.keyFilePattern"
|
||||
name="keyFile"
|
||||
ngf-accept="$ctrl.certFilePattern"
|
||||
>
|
||||
Select a file
|
||||
</button>
|
||||
<span class="ml-1 flex h-full items-center">
|
||||
{{ $ctrl.formValues.keyFile.name }}
|
||||
<pr-icon icon="'x-circle'" class-name="'icon-danger'" ng-if="!$ctrl.formValues.keyFile"></pr-icon>
|
||||
</span>
|
||||
|
@ -76,7 +97,7 @@
|
|||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="$ctrl.state.actionInProgress || !$ctrl.isFormChanged()"
|
||||
ng-click="$ctrl.save()"
|
||||
button-spinner="$ctrl.state.actionInProgress"
|
||||
|
|
|
@ -208,6 +208,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ssl-ca-file-settings></ssl-ca-file-settings>
|
||||
<ssl-certificate-settings ng-show="state.showHTTPS"></ssl-certificate-settings>
|
||||
|
||||
<div class="row">
|
||||
|
|
|
@ -40,4 +40,5 @@ export enum FeatureId {
|
|||
K8S_ROLLING_RESTART = 'k8s-rolling-restart',
|
||||
K8SINSTALL = 'k8s-install',
|
||||
K8S_ANNOTATIONS = 'k8s-annotations',
|
||||
CA_FILE = 'ca-file',
|
||||
}
|
||||
|
|
|
@ -45,6 +45,7 @@ export async function init(edition: Edition) {
|
|||
[FeatureId.K8S_ADM_ONLY_USR_INGRESS_DEPLY]: Edition.BE,
|
||||
[FeatureId.K8S_ROLLING_RESTART]: Edition.BE,
|
||||
[FeatureId.K8S_ANNOTATIONS]: Edition.BE,
|
||||
[FeatureId.CA_FILE]: Edition.BE,
|
||||
};
|
||||
|
||||
state.currentEdition = currentEdition;
|
||||
|
|
|
@ -20,7 +20,7 @@ func (hbpm *helmBinaryPackageManager) Get(getOpts options.GetOptions) ([]byte, e
|
|||
args = append(args, "--namespace", getOpts.Namespace)
|
||||
}
|
||||
|
||||
result, err := hbpm.runWithKubeConfig("get", args, getOpts.KubernetesClusterAccess)
|
||||
result, err := hbpm.runWithKubeConfig("get", args, getOpts.KubernetesClusterAccess, getOpts.Env)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to run helm get on specified args")
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package binary
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"runtime"
|
||||
|
@ -21,7 +22,7 @@ func NewHelmBinaryPackageManager(binaryPath string) *helmBinaryPackageManager {
|
|||
}
|
||||
|
||||
// runWithKubeConfig will execute run against the provided Kubernetes cluster with kubeconfig as cli arguments.
|
||||
func (hbpm *helmBinaryPackageManager) runWithKubeConfig(command string, args []string, kca *options.KubernetesClusterAccess) ([]byte, error) {
|
||||
func (hbpm *helmBinaryPackageManager) runWithKubeConfig(command string, args []string, kca *options.KubernetesClusterAccess, env []string) ([]byte, error) {
|
||||
cmdArgs := make([]string, 0)
|
||||
if kca != nil {
|
||||
cmdArgs = append(cmdArgs, "--kube-apiserver", kca.ClusterServerURL)
|
||||
|
@ -29,13 +30,13 @@ func (hbpm *helmBinaryPackageManager) runWithKubeConfig(command string, args []s
|
|||
cmdArgs = append(cmdArgs, "--kube-ca-file", kca.CertificateAuthorityFile)
|
||||
}
|
||||
cmdArgs = append(cmdArgs, args...)
|
||||
return hbpm.run(command, cmdArgs)
|
||||
return hbpm.run(command, cmdArgs, env)
|
||||
}
|
||||
|
||||
// run will execute helm command against the provided Kubernetes cluster.
|
||||
// The endpointId and authToken are dynamic params (based on the user) that allow helm to execute commands
|
||||
// in the context of the current user against specified k8s cluster.
|
||||
func (hbpm *helmBinaryPackageManager) run(command string, args []string) ([]byte, error) {
|
||||
func (hbpm *helmBinaryPackageManager) run(command string, args []string, env []string) ([]byte, error) {
|
||||
cmdArgs := make([]string, 0)
|
||||
cmdArgs = append(cmdArgs, command)
|
||||
cmdArgs = append(cmdArgs, args...)
|
||||
|
@ -49,6 +50,9 @@ func (hbpm *helmBinaryPackageManager) run(command string, args []string) ([]byte
|
|||
cmd := exec.Command(helmPath, cmdArgs...)
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Env = append(cmd.Env, env...)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, stderr.String())
|
||||
|
|
|
@ -33,7 +33,7 @@ func (hbpm *helmBinaryPackageManager) Install(installOpts options.InstallOptions
|
|||
args = append(args, "--post-renderer", installOpts.PostRenderer)
|
||||
}
|
||||
|
||||
result, err := hbpm.runWithKubeConfig("install", args, installOpts.KubernetesClusterAccess)
|
||||
result, err := hbpm.runWithKubeConfig("install", args, installOpts.KubernetesClusterAccess, installOpts.Env)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to run helm install on specified args")
|
||||
}
|
||||
|
|
|
@ -61,7 +61,7 @@ func Test_Install(t *testing.T) {
|
|||
}
|
||||
|
||||
release, err := hbpm.Install(installOpts)
|
||||
defer hbpm.run("uninstall", []string{"test-nginx"})
|
||||
defer hbpm.run("uninstall", []string{"test-nginx"}, nil)
|
||||
|
||||
is.NoError(err, "should successfully install release", release)
|
||||
})
|
||||
|
@ -73,7 +73,7 @@ func Test_Install(t *testing.T) {
|
|||
Repo: "https://charts.bitnami.com/bitnami",
|
||||
}
|
||||
release, err := hbpm.Install(installOpts)
|
||||
defer hbpm.run("uninstall", []string{release.Name})
|
||||
defer hbpm.run("uninstall", []string{release.Name}, nil)
|
||||
|
||||
is.NoError(err, "should successfully install release", release)
|
||||
})
|
||||
|
@ -92,7 +92,7 @@ func Test_Install(t *testing.T) {
|
|||
ValuesFile: values,
|
||||
}
|
||||
release, err := hbpm.Install(installOpts)
|
||||
defer hbpm.run("uninstall", []string{"test-nginx-2"})
|
||||
defer hbpm.run("uninstall", []string{"test-nginx-2"}, nil)
|
||||
|
||||
is.NoError(err, "should successfully install release", release)
|
||||
})
|
||||
|
@ -105,7 +105,7 @@ func Test_Install(t *testing.T) {
|
|||
Repo: "https://portainer.github.io/k8s/",
|
||||
}
|
||||
release, err := hbpm.Install(installOpts)
|
||||
defer hbpm.run("uninstall", []string{installOpts.Name})
|
||||
defer hbpm.run("uninstall", []string{installOpts.Name}, nil)
|
||||
|
||||
is.NoError(err, "should successfully install release", release)
|
||||
})
|
||||
|
|
|
@ -23,7 +23,7 @@ func (hbpm *helmBinaryPackageManager) List(listOpts options.ListOptions) ([]rele
|
|||
args = append(args, "--namespace", listOpts.Namespace)
|
||||
}
|
||||
|
||||
result, err := hbpm.runWithKubeConfig("list", args, listOpts.KubernetesClusterAccess)
|
||||
result, err := hbpm.runWithKubeConfig("list", args, listOpts.KubernetesClusterAccess, listOpts.Env)
|
||||
if err != nil {
|
||||
return []release.ReleaseElement{}, errors.Wrap(err, "failed to run helm list on specified args")
|
||||
}
|
||||
|
|
|
@ -50,12 +50,15 @@ func (hbpm *helmBinaryPackageManager) SearchRepo(searchRepoOpts options.SearchRe
|
|||
return nil, errRequiredSearchOptions
|
||||
}
|
||||
|
||||
// The current index.yaml is ~9MB on bitnami.
|
||||
// At a slow @2mbit download = 40s. @100bit = ~1s.
|
||||
// I'm seeing 3 - 4s over wifi.
|
||||
// Give ample time but timeout for now. Can be improved in the future
|
||||
client := http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
client := searchRepoOpts.Client
|
||||
if searchRepoOpts.Client == nil {
|
||||
// The current index.yaml is ~9MB on bitnami.
|
||||
// At a slow @2mbit download = 40s. @100bit = ~1s.
|
||||
// I'm seeing 3 - 4s over wifi.
|
||||
// Give ample time but timeout for now. Can be improved in the future
|
||||
client = &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
url, err := url.ParseRequestURI(searchRepoOpts.Repo)
|
||||
|
|
|
@ -20,7 +20,7 @@ func (hbpm *helmBinaryPackageManager) Show(showOpts options.ShowOptions) ([]byte
|
|||
"--repo", showOpts.Repo,
|
||||
}
|
||||
|
||||
result, err := hbpm.run("show", args)
|
||||
result, err := hbpm.run("show", args, showOpts.Env)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to run helm show on specified args")
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ func (hbpm *helmBinaryPackageManager) Uninstall(uninstallOpts options.UninstallO
|
|||
args = append(args, "--namespace", uninstallOpts.Namespace)
|
||||
}
|
||||
|
||||
_, err := hbpm.runWithKubeConfig("uninstall", args, uninstallOpts.KubernetesClusterAccess)
|
||||
_, err := hbpm.runWithKubeConfig("uninstall", args, uninstallOpts.KubernetesClusterAccess, uninstallOpts.Env)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to run helm uninstall on specified args")
|
||||
}
|
||||
|
|
|
@ -17,4 +17,6 @@ type GetOptions struct {
|
|||
Namespace string
|
||||
ReleaseResource releaseResource
|
||||
KubernetesClusterAccess *KubernetesClusterAccess
|
||||
|
||||
Env []string
|
||||
}
|
||||
|
|
|
@ -9,4 +9,7 @@ type InstallOptions struct {
|
|||
ValuesFile string
|
||||
PostRenderer string
|
||||
KubernetesClusterAccess *KubernetesClusterAccess
|
||||
|
||||
// Optional environment vars to pass when running helm
|
||||
Env []string
|
||||
}
|
||||
|
|
|
@ -6,4 +6,6 @@ type ListOptions struct {
|
|||
Selector string
|
||||
Namespace string
|
||||
KubernetesClusterAccess *KubernetesClusterAccess
|
||||
|
||||
Env []string
|
||||
}
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
package options
|
||||
|
||||
import "net/http"
|
||||
|
||||
type SearchRepoOptions struct {
|
||||
Repo string
|
||||
Repo string `example:"https://charts.gitlab.io/"`
|
||||
Client *http.Client `example:"&http.Client{Timeout: time.Second * 10}"`
|
||||
}
|
||||
|
|
|
@ -19,4 +19,6 @@ type ShowOptions struct {
|
|||
OutputFormat ShowOutputFormat
|
||||
Chart string
|
||||
Repo string
|
||||
|
||||
Env []string
|
||||
}
|
||||
|
|
|
@ -5,4 +5,6 @@ type UninstallOptions struct {
|
|||
Name string
|
||||
Namespace string
|
||||
KubernetesClusterAccess *KubernetesClusterAccess
|
||||
|
||||
Env []string
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ import (
|
|||
|
||||
const invalidChartRepo = "%q is not a valid chart repository or cannot be reached"
|
||||
|
||||
func ValidateHelmRepositoryURL(repoUrl string) error {
|
||||
func ValidateHelmRepositoryURL(repoUrl string, client *http.Client) error {
|
||||
if repoUrl == "" {
|
||||
return errors.New("URL is required")
|
||||
}
|
||||
|
@ -29,9 +29,12 @@ func ValidateHelmRepositoryURL(repoUrl string) error {
|
|||
|
||||
url.Path = path.Join(url.Path, "index.yaml")
|
||||
|
||||
var client = &http.Client{
|
||||
Timeout: time.Second * 10,
|
||||
if client == nil {
|
||||
client = &http.Client{
|
||||
Timeout: time.Second * 10,
|
||||
}
|
||||
}
|
||||
|
||||
response, err := client.Head(url.String())
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, invalidChartRepo, repoUrl)
|
||||
|
|
|
@ -37,7 +37,7 @@ func Test_ValidateHelmRepositoryURL(t *testing.T) {
|
|||
func(tc testCase) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := ValidateHelmRepositoryURL(tc.url)
|
||||
err := ValidateHelmRepositoryURL(tc.url, nil)
|
||||
if tc.invalid {
|
||||
is.Errorf(err, "error expected: %s", tc.url)
|
||||
} else {
|
||||
|
|
Loading…
Reference in New Issue