diff --git a/go.mod b/go.mod index d7c4141c4..b6faac113 100644 --- a/go.mod +++ b/go.mod @@ -29,8 +29,8 @@ require ( github.com/flatcar/coreos-cloudinit v1.14.1-0.20240223132627-a7bc5f0050ab github.com/flatcar/ignition v0.36.2 github.com/golang/protobuf v1.5.4 - github.com/gophercloud/gophercloud v1.14.1 - github.com/gophercloud/utils v0.0.0-20220704184730-55bdbbaec4ba + github.com/gophercloud/gophercloud/v2 v2.13.0 + github.com/gophercloud/utils/v2 v2.0.0-20260626221802-4ae35253ac13 github.com/hetznercloud/hcloud-go/v2 v2.47.0 github.com/kballard/go-shellquote v0.0.0-20150810074751-d8ec1a69a250 github.com/kylelemons/godebug v1.1.0 diff --git a/go.sum b/go.sum index 0bdb45847..87ce55d8e 100644 --- a/go.sum +++ b/go.sum @@ -203,11 +203,10 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= -github.com/gophercloud/gophercloud v0.20.0/go.mod h1:wRtmUelyIIv3CSSDI47aUwbs075O6i+LY+pXsKCBsb4= -github.com/gophercloud/gophercloud v1.14.1 h1:DTCNaTVGl8/cFu58O1JwWgis9gtISAFONqpMKNg/Vpw= -github.com/gophercloud/gophercloud v1.14.1/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= -github.com/gophercloud/utils v0.0.0-20220704184730-55bdbbaec4ba h1:PTa/ilBNI0ZE2WjSnu4drZi7W81wkYjnHPk5AEEgOGk= -github.com/gophercloud/utils v0.0.0-20220704184730-55bdbbaec4ba/go.mod h1:qOGlfG6OIJ193/c3Xt/XjOfHataNZdQcVgiu93LxBUM= +github.com/gophercloud/gophercloud/v2 v2.13.0 h1:yEyJG+kABd8x2ttTqLsomihU6Kg2YheJSZhvP/QSx+8= +github.com/gophercloud/gophercloud/v2 v2.13.0/go.mod h1:KZRLVs6gcoy/pEFdkZqFjdYqnS0emMHv66UqdM5lMjU= +github.com/gophercloud/utils/v2 v2.0.0-20260626221802-4ae35253ac13 h1:Dnid+JYEmkqPWw/vJHRUzZsjO3mdoyCRo2l5UYCqh8k= +github.com/gophercloud/utils/v2 v2.0.0-20260626221802-4ae35253ac13/go.mod h1:zpDKeT3ElgCs1UA+7B8+XlDu3R+jkK/CBC1di5qOeow= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= @@ -215,7 +214,6 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= -github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hetznercloud/hcloud-go/v2 v2.47.0 h1:SI7C4cvdYReb2aHUEQ8KBMOqxNnmd4hOZti1SbPq3Qk= @@ -273,7 +271,6 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -409,9 +406,7 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= @@ -462,7 +457,6 @@ golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= @@ -496,14 +490,12 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -516,7 +508,6 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -530,7 +521,6 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= @@ -632,7 +622,6 @@ gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/platform/api/openstack/api.go b/platform/api/openstack/api.go index 629a54098..2aa332571 100644 --- a/platform/api/openstack/api.go +++ b/platform/api/openstack/api.go @@ -1,793 +1,821 @@ -// Copyright 2018 Red Hat -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package openstack - -import ( - "fmt" - "net/url" - "os" - "strings" - "time" - - "github.com/coreos/pkg/capnslog" - "github.com/gophercloud/gophercloud" - "github.com/gophercloud/gophercloud/openstack" - "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/floatingips" - "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/keypairs" - "github.com/gophercloud/gophercloud/openstack/compute/v2/flavors" - computeImages "github.com/gophercloud/gophercloud/openstack/compute/v2/images" - "github.com/gophercloud/gophercloud/openstack/compute/v2/servers" - "github.com/gophercloud/gophercloud/openstack/imageservice/v2/imagedata" - "github.com/gophercloud/gophercloud/openstack/imageservice/v2/imageimport" - "github.com/gophercloud/gophercloud/openstack/imageservice/v2/images" - "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/security/groups" - "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/security/rules" - "github.com/gophercloud/gophercloud/openstack/networking/v2/networks" - "github.com/gophercloud/gophercloud/pagination" - ugroups "github.com/gophercloud/utils/openstack/networking/v2/extensions/security/groups" - - "github.com/flatcar/mantle/auth" - "github.com/flatcar/mantle/platform" - "github.com/flatcar/mantle/util" -) - -var ( - plog = capnslog.NewPackageLogger("github.com/flatcar/mantle", "platform/api/openstack") -) - -type Options struct { - *platform.Options - - // Config file. Defaults to $HOME/.config/openstack.json. - ConfigPath string - // Profile name - Profile string - - // Region (e.g. "regionOne") - Region string - // Instance Flavor ID - Flavor string - // Image ID - Image string - // Network ID - Network string - // Domain ID - Domain string - // Floating IP Pool - FloatingIPPool string - // Host can be used to optionally SSH into deployed VMs from the OpenStack host - Host string - // User is the one used for the SSH connection to the Host - User string - // Keyfile is the abs. path to private SSH key file for the User on the Host - Keyfile string -} - -type Server struct { - Server *servers.Server - FloatingIP *floatingips.FloatingIP -} - -type API struct { - opts *Options - computeClient *gophercloud.ServiceClient - imageClient *gophercloud.ServiceClient - networkClient *gophercloud.ServiceClient -} - -func New(opts *Options) (*API, error) { - profiles, err := auth.ReadOpenStackConfig(opts.ConfigPath) - if err != nil { - return nil, fmt.Errorf("couldn't read OpenStack config: %v", err) - } - - if opts.Profile == "" { - opts.Profile = "default" - } - profile, ok := profiles[opts.Profile] - if !ok { - return nil, fmt.Errorf("no such profile %q", opts.Profile) - } - - if opts.Domain == "" { - opts.Domain = profile.Domain - } - - osOpts := gophercloud.AuthOptions{ - IdentityEndpoint: profile.AuthURL, - TenantID: profile.TenantID, - TenantName: profile.TenantName, - Username: profile.Username, - Password: profile.Password, - DomainID: profile.DomainID, - // Enable automatic re‑authentication so that long‑running - // kola test suites do not fail once the Keystone token - // expires (typically after one hour). With AllowReauth set - // to true, gophercloud will transparently obtain a fresh - // token whenever it receives a 401 response, preventing - // intermittent "Authentication failed" errors during console - // log retrieval, security‑group operations, etc. - // See https://pkg.go.dev/github.com/gophercloud/gophercloud#AuthOptions - AllowReauth: true, - } - - provider, err := openstack.AuthenticatedClient(osOpts) - if err != nil { - return nil, fmt.Errorf("failed creating provider: %v", err) - } - - if opts.Region == "" { - opts.Region = profile.Region - } - - computeClient, err := openstack.NewComputeV2(provider, gophercloud.EndpointOpts{ - Name: "nova", - Region: opts.Region, - }) - if err != nil { - return nil, fmt.Errorf("failed to create compute client: %v", err) - } - - imageClient, err := openstack.NewImageServiceV2(provider, gophercloud.EndpointOpts{ - Name: "glance", - Region: opts.Region, - }) - if err != nil { - return nil, fmt.Errorf("failed to create image client: %v", err) - } - - networkClient, err := openstack.NewNetworkV2(provider, gophercloud.EndpointOpts{ - Name: "neutron", - Region: opts.Region, - }) - - a := &API{ - opts: opts, - computeClient: computeClient, - imageClient: imageClient, - networkClient: networkClient, - } - - if a.opts.Flavor != "" { - tmp, err := a.resolveFlavor() - if err != nil { - return nil, fmt.Errorf("resolving flavor: %v", err) - } - a.opts.Flavor = tmp - } - - if a.opts.Image != "" { - tmp, err := a.ResolveImage(a.opts.Image) - if err != nil { - return nil, fmt.Errorf("resolving image: %v", err) - } - a.opts.Image = tmp - } - - if a.opts.Network != "" { - tmp, err := a.resolveNetwork() - if err != nil { - return nil, fmt.Errorf("resolving network: %v", err) - } - a.opts.Network = tmp - } - - if a.opts.FloatingIPPool == "" { - a.opts.FloatingIPPool = profile.FloatingIPPool - } - - return a, nil -} - -func unwrapPages(pager pagination.Pager, allowEmpty bool) (pagination.Page, error) { - if pager.Err != nil { - return nil, fmt.Errorf("retrieving pager: %v", pager.Err) - } - - pages, err := pager.AllPages() - if err != nil { - return nil, fmt.Errorf("retrieving pages: %v", err) - } - - if !allowEmpty { - empty, err := pages.IsEmpty() - if err != nil { - return nil, fmt.Errorf("parsing pages: %v", err) - } - if empty { - return nil, fmt.Errorf("empty pager") - } - } - return pages, nil -} - -func (a *API) resolveFlavor() (string, error) { - pager := flavors.ListDetail(a.computeClient, flavors.ListOpts{}) - - pages, err := unwrapPages(pager, false) - if err != nil { - return "", fmt.Errorf("flavors: %v", err) - } - - flavors, err := flavors.ExtractFlavors(pages) - if err != nil { - return "", fmt.Errorf("extracting flavors: %v", err) - } - - for _, flavor := range flavors { - if flavor.ID == a.opts.Flavor || flavor.Name == a.opts.Flavor { - return flavor.ID, nil - } - } - - return "", fmt.Errorf("specified flavor %q not found", a.opts.Flavor) -} - -func (a *API) ResolveImage(img string) (string, error) { - pager := computeImages.ListDetail(a.computeClient, computeImages.ListOpts{}) - - pages, err := unwrapPages(pager, false) - if err != nil { - return "", fmt.Errorf("images: %v", err) - } - - images, err := computeImages.ExtractImages(pages) - if err != nil { - return "", fmt.Errorf("extracting images: %v", err) - } - - for _, image := range images { - if image.ID == img || image.Name == img { - return image.ID, nil - } - } - - return "", fmt.Errorf("specified image %q not found", img) -} - -func (a *API) resolveNetwork() (string, error) { - networks, err := a.getNetworks() - if err != nil { - return "", err - } - - for _, network := range networks { - if network.ID == a.opts.Network || network.Name == a.opts.Network { - return network.ID, nil - } - } - - return "", fmt.Errorf("specified network %q not found", a.opts.Network) -} - -func (a *API) PreflightCheck() error { - if err := servers.List(a.computeClient, servers.ListOpts{}).Err; err != nil { - return fmt.Errorf("listing servers: %v", err) - } - return nil -} - -func (a *API) CreateServer(name, sshKeyID, userdata string) (*Server, error) { - networkID := a.opts.Network - if networkID == "" { - networks, err := a.getNetworks() - if err != nil { - return nil, fmt.Errorf("getting network: %v", err) - } - networkID = networks[0].ID - } - - securityGroup, err := a.getSecurityGroup() - if err != nil { - return nil, fmt.Errorf("retrieving security group: %v", err) - } - - server, err := servers.Create(a.computeClient, keypairs.CreateOptsExt{ - CreateOptsBuilder: servers.CreateOpts{ - Name: name, - FlavorRef: a.opts.Flavor, - ImageRef: a.opts.Image, - Metadata: map[string]string{ - "CreatedBy": "mantle", - }, - SecurityGroups: []string{securityGroup}, - Networks: []servers.Network{ - { - UUID: networkID, - }, - }, - UserData: []byte(userdata), - }, - KeyName: sshKeyID, - }).Extract() - if err != nil { - return nil, fmt.Errorf("creating server: %v", err) - } - - serverID := server.ID - - err = util.WaitUntilReady(5*time.Minute, 10*time.Second, func() (bool, error) { - var err error - server, err = servers.Get(a.computeClient, serverID).Extract() - if err != nil { - return false, err - } - return server.Status == "ACTIVE", nil - }) - if err != nil { - a.DeleteServer(serverID) - return nil, fmt.Errorf("waiting for instance to run: %v", err) - } - - var floatingip *floatingips.FloatingIP - if a.opts.FloatingIPPool != "" { - floatingip, err = a.createFloatingIP() - if err != nil { - a.DeleteServer(serverID) - return nil, fmt.Errorf("creating floating ip: %v", err) - } - err = floatingips.AssociateInstance(a.computeClient, serverID, floatingips.AssociateOpts{ - FloatingIP: floatingip.IP, - }).ExtractErr() - if err != nil { - a.DeleteServer(serverID) - // Explicitly delete the floating ip as DeleteServer only deletes floating IPs that are - // associated with servers - a.deleteFloatingIP(floatingip.ID) - return nil, fmt.Errorf("associating floating ip: %v", err) - } - - server, err = servers.Get(a.computeClient, serverID).Extract() - if err != nil { - a.DeleteServer(serverID) - return nil, fmt.Errorf("retrieving server info: %v", err) - } - } - - return &Server{ - Server: server, - FloatingIP: floatingip, - }, nil -} - -func (a *API) getNetworks() ([]networks.Network, error) { - pager := networks.List(a.networkClient, networks.ListOpts{}) - - pages, err := unwrapPages(pager, false) - if err != nil { - return nil, fmt.Errorf("networks: %v", err) - } - - networks, err := networks.ExtractNetworks(pages) - if err != nil { - return nil, fmt.Errorf("extracting networks: %v", err) - } - return networks, nil -} - -func (a *API) getSecurityGroup() (string, error) { - id, err := ugroups.IDFromName(a.networkClient, "kola") - if err != nil { - if _, ok := err.(gophercloud.ErrResourceNotFound); ok { - return a.createSecurityGroup() - } - return "", fmt.Errorf("finding security group: %v", err) - } - return id, nil -} - -func (a *API) createSecurityGroup() (string, error) { - securityGroup, err := groups.Create(a.networkClient, groups.CreateOpts{ - Name: "kola", - }).Extract() - if err != nil { - return "", fmt.Errorf("creating security group: %v", err) - } - - ruleSet := []struct { - Direction rules.RuleDirection - EtherType rules.RuleEtherType - Protocol rules.RuleProtocol - PortRangeMin int - PortRangeMax int - RemoteGroupID string - RemoteIPPrefix string - }{ - { - Direction: rules.DirIngress, - EtherType: rules.EtherType4, - RemoteGroupID: securityGroup.ID, - }, - { - Direction: rules.DirIngress, - EtherType: rules.EtherType4, - Protocol: rules.ProtocolTCP, - PortRangeMin: 22, - PortRangeMax: 22, - RemoteIPPrefix: "0.0.0.0/0", - }, - { - Direction: rules.DirIngress, - EtherType: rules.EtherType6, - RemoteGroupID: securityGroup.ID, - }, - { - Direction: rules.DirIngress, - EtherType: rules.EtherType4, - Protocol: rules.ProtocolTCP, - PortRangeMin: 2379, - PortRangeMax: 2380, - RemoteIPPrefix: "0.0.0.0/0", - }, - } - - for _, rule := range ruleSet { - _, err = rules.Create(a.networkClient, rules.CreateOpts{ - Direction: rule.Direction, - EtherType: rule.EtherType, - SecGroupID: securityGroup.ID, - PortRangeMax: rule.PortRangeMax, - PortRangeMin: rule.PortRangeMin, - Protocol: rule.Protocol, - RemoteGroupID: rule.RemoteGroupID, - RemoteIPPrefix: rule.RemoteIPPrefix, - }).Extract() - if err != nil { - a.deleteSecurityGroup(securityGroup.ID) - return "", fmt.Errorf("adding security rule: %v", err) - } - } - - return securityGroup.ID, nil -} - -func (a *API) deleteSecurityGroup(id string) error { - return groups.Delete(a.networkClient, id).ExtractErr() -} - -func (a *API) createFloatingIP() (*floatingips.FloatingIP, error) { - return floatingips.Create(a.computeClient, floatingips.CreateOpts{ - Pool: a.opts.FloatingIPPool, - }).Extract() -} - -func (a *API) disassociateFloatingIP(serverID, id string) error { - return floatingips.DisassociateInstance(a.computeClient, serverID, floatingips.DisassociateOpts{ - FloatingIP: id, - }).ExtractErr() -} - -func (a *API) deleteFloatingIP(id string) error { - return floatingips.Delete(a.computeClient, id).ExtractErr() -} - -func (a *API) findFloatingIP(serverID string) (*floatingips.FloatingIP, error) { - pager := floatingips.List(a.computeClient) - - pages, err := unwrapPages(pager, true) - if err != nil { - return nil, fmt.Errorf("floating ips: %v", err) - } - - floatingiplist, err := floatingips.ExtractFloatingIPs(pages) - if err != nil { - return nil, fmt.Errorf("extracting floating ips: %v", err) - } - - for _, floatingip := range floatingiplist { - if floatingip.InstanceID == serverID { - return &floatingip, nil - } - } - - return nil, nil -} - -// Deletes the server, and disassociates & deletes any floating IP associated with the given server. -func (a *API) DeleteServer(id string) error { - fip, err := a.findFloatingIP(id) - if err != nil { - return err - } - if fip != nil { - if err := a.disassociateFloatingIP(id, fip.IP); err != nil { - return fmt.Errorf("couldn't disassociate floating ip %s from server %s: %v", fip.ID, id, err) - } - if err := a.deleteFloatingIP(fip.ID); err != nil { - // if the deletion of this floating IP fails then mantle cannot detect the floating IP was tied to the - // server anymore. as such warn and continue deleting the server. - plog.Warningf("couldn't delete floating ip %s: %v", fip.ID, err) - } - } - - if err := servers.Delete(a.computeClient, id).ExtractErr(); err != nil { - return fmt.Errorf("deleting server: %v: %v", id, err) - } - - return nil -} - -func (a *API) GetConsoleOutput(id string) (string, error) { - return servers.ShowConsoleOutput(a.computeClient, id, servers.ShowConsoleOutputOpts{}).Extract() -} - -func (a *API) webUpload(ID, URI string) error { - createOpts := imageimport.CreateOpts{ - Name: imageimport.WebDownloadMethod, - URI: URI, - } - - if err := imageimport.Create(a.imageClient, ID, createOpts).ExtractErr(); err != nil { - return fmt.Errorf("importing web image: %w", err) - } - - return nil -} - -func (a *API) UploadImage(name, path string) (string, error) { - image, err := images.Create(a.imageClient, images.CreateOpts{ - Name: name, - ContainerFormat: "bare", - DiskFormat: "qcow2", - Tags: []string{"mantle"}, - }).Extract() - if err != nil { - return "", fmt.Errorf("creating image: %v", err) - } - - u, err := url.Parse(path) - if err == nil && u.Scheme != "" && image.ID != "" { - plog.Debug("creating image from URL") - if err := a.webUpload(image.ID, path); err != nil { - a.DeleteImage(image.ID) - return "", fmt.Errorf("web uploading: %w", err) - } - - // It usually takes around 10 seconds to extract the image. - if err := util.WaitUntilReady(1*time.Minute, 5*time.Second, func() (bool, error) { - image, err = images.Get(a.imageClient, image.ID).Extract() - if err != nil { - return false, fmt.Errorf("getting image status: %w", err) - } - - return image.Status == images.ImageStatusActive, nil - }); err != nil { - a.DeleteImage(image.ID) - return "", fmt.Errorf("getting image active: %w", err) - } - - return image.ID, nil - } - - plog.Debug("creating image from source file") - data, err := os.Open(path) - if err != nil { - a.DeleteImage(image.ID) - return "", fmt.Errorf("opening image file: %v", err) - } - defer data.Close() - - err = imagedata.Upload(a.imageClient, image.ID, data).ExtractErr() - if err != nil { - a.DeleteImage(image.ID) - return "", fmt.Errorf("uploading image data: %v", err) - } - - return image.ID, nil -} - -func (a *API) DeleteImage(imageID string) error { - return images.Delete(a.imageClient, imageID).ExtractErr() -} - -func (a *API) PruneKeys(olderThan time.Duration) error { - // Build a set of keypair names that are still in use by active servers so - // that we don't delete keys that are currently required. - usedKeys := make(map[string]struct{}) - - srvPager := servers.List(a.computeClient, servers.ListOpts{}) - srvPages, err := unwrapPages(srvPager, true) - if err != nil { - return fmt.Errorf("listing servers: %v", err) - } - - srvList, err := servers.ExtractServers(srvPages) - if err != nil { - return fmt.Errorf("extracting servers: %v", err) - } - - for _, s := range srvList { - if s.KeyName != "" { - usedKeys[s.KeyName] = struct{}{} - } - } - - // List all keypairs in the project. - kpPager := keypairs.List(a.computeClient, keypairs.ListOpts{}) - kpPages, err := unwrapPages(kpPager, true) - if err != nil { - return fmt.Errorf("listing keypairs: %v", err) - } - - kpList, err := keypairs.ExtractKeyPairs(kpPages) - if err != nil { - return fmt.Errorf("extracting keypairs: %v", err) - } - - now := time.Now() - - for _, kp := range kpList { - // Skip keypairs that are still in use. - if _, inUse := usedKeys[kp.Name]; inUse { - continue - } - - // Retrieve detailed information in order to obtain the optional - // `created_at` field. - var detail struct { - Keypair struct { - CreatedAt string `json:"created_at"` - } `json:"keypair"` - } - - if err := keypairs.Get(a.computeClient, kp.Name, nil).ExtractInto(&detail); err != nil { - // If we fail to obtain details, skip deletion to be safe. - plog.Warningf("could not get details for keypair %s: %v", kp.Name, err) - continue - } - - if detail.Keypair.CreatedAt == "" { - // Missing creation timestamp – skip. - continue - } - - // Attempt to parse the timestamp using a small set of common layouts. - var createdTime time.Time - var parseErr error - for _, layout := range []string{ - time.RFC3339, - "2006-01-02T15:04:05.999999Z07:00", // micro-seconds + TZ - "2006-01-02T15:04:05.999999", // micro-seconds, no TZ - "2006-01-02T15:04:05", // seconds, no TZ - } { - createdTime, parseErr = time.Parse(layout, detail.Keypair.CreatedAt) - if parseErr == nil { - // If the layout did not specify timezone information (no "Z07"), - // assume the timestamp is in UTC, which is what OpenStack typically - // uses internally. - if !strings.Contains(layout, "Z07") { - createdTime = createdTime.UTC() - } - break - } - } - if parseErr != nil { - plog.Warningf("unable to parse created_at for keypair %s: %v", kp.Name, parseErr) - continue - } - - if now.Sub(createdTime) > olderThan { - if err := a.DeleteKey(kp.Name); err != nil { - plog.Warningf("failed deleting stale keypair %s: %v", kp.Name, err) - } else { - plog.Infof("deleted stale keypair %s (age %s)", kp.Name, now.Sub(createdTime)) - } - } else { - plog.Infof("skipping keypair %s (age %s)", kp.Name, now.Sub(createdTime)) - } - } - - return nil -} - -func (a *API) AddKey(name, key string) error { - _, err := keypairs.Create(a.computeClient, keypairs.CreateOpts{ - Name: name, - PublicKey: key, - }).Extract() - return err -} - -func (a *API) DeleteKey(name string) error { - return keypairs.Delete(a.computeClient, name, nil).ExtractErr() -} - -func (a *API) listServersWithMetadata(metadata map[string]string) ([]servers.Server, error) { - pager := servers.List(a.computeClient, servers.ListOpts{}) - - pages, err := unwrapPages(pager, true) - if err != nil { - return nil, fmt.Errorf("servers: %v", err) - } - - allServers, err := servers.ExtractServers(pages) - if err != nil { - return nil, fmt.Errorf("extracting servers: %v", err) - } - var retServers []servers.Server - for _, server := range allServers { - isMatch := true - for key, val := range metadata { - if value, ok := server.Metadata[key]; !ok || val != value { - isMatch = false - break - } - } - if isMatch { - retServers = append(retServers, server) - } - } - return retServers, nil -} - -func (a *API) listImagesWithTags(tags []string) ([]images.Image, error) { - listOpts := images.ListOpts{ - Tags: tags, - } - - allPages, err := images.List(a.imageClient, listOpts).AllPages() - if err != nil { - return nil, fmt.Errorf("listing images: %w", err) - } - - allImages, err := images.ExtractImages(allPages) - if err != nil { - return nil, fmt.Errorf("extracting image list: %w", err) - } - - return allImages, nil -} - -func (a *API) GC(gracePeriod time.Duration) error { - threshold := time.Now().Add(-gracePeriod) - - servers, err := a.listServersWithMetadata(map[string]string{ - "CreatedBy": "mantle", - }) - if err != nil { - return err - } - for _, server := range servers { - if strings.Contains(server.Status, "DELETED") || server.Created.After(threshold) { - continue - } - - if err := a.DeleteServer(server.ID); err != nil { - return fmt.Errorf("couldn't delete server %s: %v", server.ID, err) - } - } - - images, err := a.listImagesWithTags([]string{"mantle"}) - if err != nil { - return fmt.Errorf("listing Mantle images: %w", err) - } - - for _, image := range images { - if image.CreatedAt.After(threshold) { - continue - } - - if err := a.DeleteImage(image.ID); err != nil { - return fmt.Errorf("deleting image with name: %s", image.Name) - } - } - - err = a.PruneKeys(gracePeriod) - if err != nil { - return fmt.Errorf("pruning keys: %v", err) - } - - return nil -} +// Copyright 2018 Red Hat +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package openstack + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + "time" + + "github.com/coreos/pkg/capnslog" + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/floatingips" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/ports" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/keypairs" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/flavors" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/imagedata" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/imageimport" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/security/groups" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/security/rules" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/networks" + "github.com/gophercloud/gophercloud/v2/pagination" + ugroups "github.com/gophercloud/utils/v2/openstack/networking/v2/extensions/security/groups" + + "github.com/flatcar/mantle/auth" + "github.com/flatcar/mantle/platform" + "github.com/flatcar/mantle/util" +) + +var ( + plog = capnslog.NewPackageLogger("github.com/flatcar/mantle", "platform/api/openstack") +) + +type Options struct { + *platform.Options + + // Config file. Defaults to $HOME/.config/openstack.json. + ConfigPath string + // Profile name + Profile string + + // Region (e.g. "regionOne") + Region string + // Instance Flavor ID + Flavor string + // Image ID + Image string + // Network ID + Network string + // Domain ID + Domain string + // Floating IP Pool + FloatingIPPool string + // Host can be used to optionally SSH into deployed VMs from the OpenStack host + Host string + // User is the one used for the SSH connection to the Host + User string + // Keyfile is the abs. path to private SSH key file for the User on the Host + Keyfile string +} + +type Server struct { + Server *servers.Server + FloatingIP *floatingips.FloatingIP +} + +type API struct { + opts *Options + computeClient *gophercloud.ServiceClient + imageClient *gophercloud.ServiceClient + networkClient *gophercloud.ServiceClient +} + +func New(opts *Options) (*API, error) { + profiles, err := auth.ReadOpenStackConfig(opts.ConfigPath) + if err != nil { + return nil, fmt.Errorf("couldn't read OpenStack config: %v", err) + } + + if opts.Profile == "" { + opts.Profile = "default" + } + profile, ok := profiles[opts.Profile] + if !ok { + return nil, fmt.Errorf("no such profile %q", opts.Profile) + } + + if opts.Domain == "" { + opts.Domain = profile.Domain + } + + osOpts := gophercloud.AuthOptions{ + IdentityEndpoint: profile.AuthURL, + TenantID: profile.TenantID, + TenantName: profile.TenantName, + Username: profile.Username, + Password: profile.Password, + DomainID: profile.DomainID, + // Enable automatic re‑authentication so that long‑running + // kola test suites do not fail once the Keystone token + // expires (typically after one hour). With AllowReauth set + // to true, gophercloud will transparently obtain a fresh + // token whenever it receives a 401 response, preventing + // intermittent "Authentication failed" errors during console + // log retrieval, security‑group operations, etc. + // See https://pkg.go.dev/github.com/gophercloud/gophercloud#AuthOptions + AllowReauth: true, + } + + provider, err := openstack.AuthenticatedClient(context.TODO(), osOpts) + if err != nil { + return nil, fmt.Errorf("failed creating provider: %v", err) + } + + if opts.Region == "" { + opts.Region = profile.Region + } + + computeClient, err := openstack.NewComputeV2(provider, gophercloud.EndpointOpts{ + Name: "nova", + Region: opts.Region, + }) + if err != nil { + return nil, fmt.Errorf("failed to create compute client: %v", err) + } + + imageClient, err := openstack.NewImageV2(provider, gophercloud.EndpointOpts{ + Name: "glance", + Region: opts.Region, + }) + if err != nil { + return nil, fmt.Errorf("failed to create image client: %v", err) + } + + networkClient, err := openstack.NewNetworkV2(provider, gophercloud.EndpointOpts{ + Name: "neutron", + Region: opts.Region, + }) + + a := &API{ + opts: opts, + computeClient: computeClient, + imageClient: imageClient, + networkClient: networkClient, + } + + if a.opts.Flavor != "" { + tmp, err := a.resolveFlavor() + if err != nil { + return nil, fmt.Errorf("resolving flavor: %v", err) + } + a.opts.Flavor = tmp + } + + if a.opts.Image != "" { + tmp, err := a.ResolveImage(a.opts.Image) + if err != nil { + return nil, fmt.Errorf("resolving image: %v", err) + } + a.opts.Image = tmp + } + + if a.opts.Network != "" { + tmp, err := a.resolveNetwork() + if err != nil { + return nil, fmt.Errorf("resolving network: %v", err) + } + a.opts.Network = tmp + } + + if a.opts.FloatingIPPool == "" { + a.opts.FloatingIPPool = profile.FloatingIPPool + } + + return a, nil +} + +func unwrapPages(ctx context.Context, pager pagination.Pager, allowEmpty bool) (pagination.Page, error) { + if pager.Err != nil { + return nil, fmt.Errorf("retrieving pager: %v", pager.Err) + } + + pages, err := pager.AllPages(ctx) + if err != nil { + return nil, fmt.Errorf("retrieving pages: %v", err) + } + + if !allowEmpty { + empty, err := pages.IsEmpty() + if err != nil { + return nil, fmt.Errorf("parsing pages: %v", err) + } + if empty { + return nil, fmt.Errorf("empty pager") + } + } + return pages, nil +} + +func (a *API) resolveFlavor() (string, error) { + pager := flavors.ListDetail(a.computeClient, flavors.ListOpts{}) + + pages, err := unwrapPages(context.TODO(), pager, false) + if err != nil { + return "", fmt.Errorf("flavors: %v", err) + } + + flavors, err := flavors.ExtractFlavors(pages) + if err != nil { + return "", fmt.Errorf("extracting flavors: %v", err) + } + + for _, flavor := range flavors { + if flavor.ID == a.opts.Flavor || flavor.Name == a.opts.Flavor { + return flavor.ID, nil + } + } + + return "", fmt.Errorf("specified flavor %q not found", a.opts.Flavor) +} + + +func (a *API) ResolveImage(img string) (string, error) { + pager := images.List(a.imageClient, images.ListOpts{}) + + pages, err := unwrapPages(context.TODO(), pager, false) + if err != nil { + return "", fmt.Errorf("images: %v", err) + } + + imageList, err := images.ExtractImages(pages) + if err != nil { + return "", fmt.Errorf("extracting images: %v", err) + } + + for _, image := range imageList { + if image.ID == img || image.Name == img { + return image.ID, nil + } + } + + return "", fmt.Errorf("specified image %q not found", img) +} + +func (a *API) resolveNetwork() (string, error) { + networks, err := a.getNetworks() + if err != nil { + return "", err + } + + for _, network := range networks { + if network.ID == a.opts.Network || network.Name == a.opts.Network { + return network.ID, nil + } + } + + return "", fmt.Errorf("specified network %q not found", a.opts.Network) +} + +func (a *API) PreflightCheck() error { + if err := servers.List(a.computeClient, servers.ListOpts{}).Err; err != nil { + return fmt.Errorf("listing servers: %v", err) + } + return nil +} + +func (a *API) CreateServer(name, sshKeyID, userdata string) (*Server, error) { + networkID := a.opts.Network + if networkID == "" { + networks, err := a.getNetworks() + if err != nil { + return nil, fmt.Errorf("getting network: %v", err) + } + networkID = networks[0].ID + } + + securityGroup, err := a.getSecurityGroup() + if err != nil { + return nil, fmt.Errorf("retrieving security group: %v", err) + } + + server, err := servers.Create(context.TODO(), a.computeClient, keypairs.CreateOptsExt{ + CreateOptsBuilder: servers.CreateOpts{ + Name: name, + FlavorRef: a.opts.Flavor, + ImageRef: a.opts.Image, + Metadata: map[string]string{ + "CreatedBy": "mantle", + }, + SecurityGroups: []string{securityGroup}, + Networks: []servers.Network{ + { + UUID: networkID, + }, + }, + UserData: []byte(userdata), + }, + KeyName: sshKeyID, + }, nil).Extract() + if err != nil { + return nil, fmt.Errorf("creating server: %v", err) + } + + serverID := server.ID + + err = util.WaitUntilReady(5*time.Minute, 10*time.Second, func() (bool, error) { + var err error + server, err = servers.Get(context.TODO(), a.computeClient, serverID).Extract() + if err != nil { + return false, err + } + return server.Status == "ACTIVE", nil + }) + if err != nil { + a.DeleteServer(serverID) + return nil, fmt.Errorf("waiting for instance to run: %v", err) + } + + var floatingip *floatingips.FloatingIP + if a.opts.FloatingIPPool != "" { + floatingip, err = a.createFloatingIP() + if err != nil { + a.DeleteServer(serverID) + return nil, fmt.Errorf("creating floating ip: %v", err) + } + + // Find the port for the server + pager := ports.List(a.networkClient, ports.ListOpts{DeviceID: serverID}) + pages, err := unwrapPages(context.TODO(), pager, true) + if err == nil { + portList, err := ports.ExtractPorts(pages) + if err == nil && len(portList) > 0 { + _, err = floatingips.Update(context.TODO(), a.networkClient, floatingip.ID, floatingips.UpdateOpts{ + PortID: &portList[0].ID, + }).Extract() + } + } + + server, err = servers.Get(context.TODO(), a.computeClient, serverID).Extract() + if err != nil { + a.DeleteServer(serverID) + return nil, fmt.Errorf("retrieving server info: %v", err) + } + } + + return &Server{ + Server: server, + FloatingIP: floatingip, + }, nil +} + +func (a *API) getNetworks() ([]networks.Network, error) { + pager := networks.List(a.networkClient, networks.ListOpts{}) + + pages, err := unwrapPages(context.TODO(), pager, false) + if err != nil { + return nil, fmt.Errorf("networks: %v", err) + } + + networks, err := networks.ExtractNetworks(pages) + if err != nil { + return nil, fmt.Errorf("extracting networks: %v", err) + } + return networks, nil +} + +func (a *API) getSecurityGroup() (string, error) { + id, err := ugroups.IDFromName(context.TODO(), a.networkClient, "kola") + if err != nil { + if _, ok := err.(gophercloud.ErrResourceNotFound); ok { + return a.createSecurityGroup() + } + return "", fmt.Errorf("finding security group: %v", err) + } + return id, nil +} + +func (a *API) createSecurityGroup() (string, error) { + securityGroup, err := groups.Create(context.TODO(), a.networkClient, groups.CreateOpts{ + Name: "kola", + }).Extract() + if err != nil { + return "", fmt.Errorf("creating security group: %v", err) + } + + ruleSet := []struct { + Direction rules.RuleDirection + EtherType rules.RuleEtherType + Protocol rules.RuleProtocol + PortRangeMin int + PortRangeMax int + RemoteGroupID string + RemoteIPPrefix string + }{ + { + Direction: rules.DirIngress, + EtherType: rules.EtherType4, + RemoteGroupID: securityGroup.ID, + }, + { + Direction: rules.DirIngress, + EtherType: rules.EtherType4, + Protocol: rules.ProtocolTCP, + PortRangeMin: 22, + PortRangeMax: 22, + RemoteIPPrefix: "0.0.0.0/0", + }, + { + Direction: rules.DirIngress, + EtherType: rules.EtherType6, + RemoteGroupID: securityGroup.ID, + }, + { + Direction: rules.DirIngress, + EtherType: rules.EtherType4, + Protocol: rules.ProtocolTCP, + PortRangeMin: 2379, + PortRangeMax: 2380, + RemoteIPPrefix: "0.0.0.0/0", + }, + } + + for _, rule := range ruleSet { + _, err = rules.Create(context.TODO(), a.networkClient, rules.CreateOpts{ + Direction: rule.Direction, + EtherType: rule.EtherType, + SecGroupID: securityGroup.ID, + PortRangeMax: rule.PortRangeMax, + PortRangeMin: rule.PortRangeMin, + Protocol: rule.Protocol, + RemoteGroupID: rule.RemoteGroupID, + RemoteIPPrefix: rule.RemoteIPPrefix, + }).Extract() + if err != nil { + a.deleteSecurityGroup(securityGroup.ID) + return "", fmt.Errorf("adding security rule: %v", err) + } + } + + return securityGroup.ID, nil +} + +func (a *API) deleteSecurityGroup(id string) error { + return groups.Delete(context.TODO(), a.networkClient, id).ExtractErr() +} + + +func (a *API) createFloatingIP() (*floatingips.FloatingIP, error) { + // First resolve the pool name to a network ID + networks, err := a.getNetworks() + if err != nil { + return nil, err + } + var netID string + for _, n := range networks { + if n.Name == a.opts.FloatingIPPool { + netID = n.ID + break + } + } + if netID == "" { + return nil, fmt.Errorf("could not find network ID for floating IP pool %s", a.opts.FloatingIPPool) + } + + return floatingips.Create(context.TODO(), a.networkClient, floatingips.CreateOpts{ + FloatingNetworkID: netID, + }).Extract() +} + +func (a *API) disassociateFloatingIP(serverID, id string) error { + var emptyPort string + _, err := floatingips.Update(context.TODO(), a.networkClient, id, floatingips.UpdateOpts{ + PortID: &emptyPort, + }).Extract() + return err +} + +func (a *API) deleteFloatingIP(id string) error { + return floatingips.Delete(context.TODO(), a.networkClient, id).ExtractErr() +} + +func (a *API) findFloatingIP(serverID string) (*floatingips.FloatingIP, error) { + pager := ports.List(a.networkClient, ports.ListOpts{ + DeviceID: serverID, + }) + pages, err := unwrapPages(context.TODO(), pager, true) + if err != nil { + return nil, err + } + portList, err := ports.ExtractPorts(pages) + if err != nil || len(portList) == 0 { + return nil, err + } + + fipPager := floatingips.List(a.networkClient, floatingips.ListOpts{ + PortID: portList[0].ID, + }) + fipPages, err := unwrapPages(context.TODO(), fipPager, true) + if err != nil { + return nil, err + } + fips, err := floatingips.ExtractFloatingIPs(fipPages) + if err != nil || len(fips) == 0 { + return nil, err + } + return &fips[0], nil +} + +// Deletes the server, and disassociates & deletes any floating IP associated with the given server. +func (a *API) DeleteServer(id string) error { + fip, err := a.findFloatingIP(id) + if err != nil { + return err + } + if fip != nil { + if err := a.disassociateFloatingIP(id, fip.FloatingIP); err != nil { + return fmt.Errorf("couldn't disassociate floating ip %s from server %s: %v", fip.ID, id, err) + } + if err := a.deleteFloatingIP(fip.ID); err != nil { + // if the deletion of this floating IP fails then mantle cannot detect the floating IP was tied to the + // server anymore. as such warn and continue deleting the server. + plog.Warningf("couldn't delete floating ip %s: %v", fip.ID, err) + } + } + + if err := servers.Delete(context.TODO(), a.computeClient, id).ExtractErr(); err != nil { + return fmt.Errorf("deleting server: %v: %v", id, err) + } + + return nil +} + +func (a *API) GetConsoleOutput(id string) (string, error) { + return servers.ShowConsoleOutput(context.TODO(), a.computeClient, id, servers.ShowConsoleOutputOpts{}).Extract() +} + +func (a *API) webUpload(ID, URI string) error { + createOpts := imageimport.CreateOpts{ + Name: imageimport.WebDownloadMethod, + URI: URI, + } + + if err := imageimport.Create(context.TODO(), a.imageClient, ID, createOpts).ExtractErr(); err != nil { + return fmt.Errorf("importing web image: %w", err) + } + + return nil +} + +func (a *API) UploadImage(name, path string) (string, error) { + image, err := images.Create(context.TODO(), a.imageClient, images.CreateOpts{ + Name: name, + ContainerFormat: "bare", + DiskFormat: "qcow2", + Tags: []string{"mantle"}, + }).Extract() + if err != nil { + return "", fmt.Errorf("creating image: %v", err) + } + + u, err := url.Parse(path) + if err == nil && u.Scheme != "" && image.ID != "" { + plog.Debug("creating image from URL") + if err := a.webUpload(image.ID, path); err != nil { + a.DeleteImage(image.ID) + return "", fmt.Errorf("web uploading: %w", err) + } + + // It usually takes around 10 seconds to extract the image. + if err := util.WaitUntilReady(1*time.Minute, 5*time.Second, func() (bool, error) { + image, err = images.Get(context.TODO(), a.imageClient, image.ID).Extract() + if err != nil { + return false, fmt.Errorf("getting image status: %w", err) + } + + return image.Status == images.ImageStatusActive, nil + }); err != nil { + a.DeleteImage(image.ID) + return "", fmt.Errorf("getting image active: %w", err) + } + + return image.ID, nil + } + + plog.Debug("creating image from source file") + data, err := os.Open(path) + if err != nil { + a.DeleteImage(image.ID) + return "", fmt.Errorf("opening image file: %v", err) + } + defer data.Close() + + err = imagedata.Upload(context.TODO(), a.imageClient, image.ID, data).ExtractErr() + if err != nil { + a.DeleteImage(image.ID) + return "", fmt.Errorf("uploading image data: %v", err) + } + + return image.ID, nil +} + +func (a *API) DeleteImage(imageID string) error { + return images.Delete(context.TODO(), a.imageClient, imageID).ExtractErr() +} + +func (a *API) PruneKeys(olderThan time.Duration) error { + // Build a set of keypair names that are still in use by active servers so + // that we don't delete keys that are currently required. + usedKeys := make(map[string]struct{}) + + srvPager := servers.List(a.computeClient, servers.ListOpts{}) + srvPages, err := unwrapPages(context.TODO(), srvPager, true) + if err != nil { + return fmt.Errorf("listing servers: %v", err) + } + + srvList, err := servers.ExtractServers(srvPages) + if err != nil { + return fmt.Errorf("extracting servers: %v", err) + } + + for _, s := range srvList { + if s.KeyName != "" { + usedKeys[s.KeyName] = struct{}{} + } + } + + // List all keypairs in the project. + kpPager := keypairs.List(a.computeClient, keypairs.ListOpts{}) + kpPages, err := unwrapPages(context.TODO(), kpPager, true) + if err != nil { + return fmt.Errorf("listing keypairs: %v", err) + } + + kpList, err := keypairs.ExtractKeyPairs(kpPages) + if err != nil { + return fmt.Errorf("extracting keypairs: %v", err) + } + + now := time.Now() + + for _, kp := range kpList { + // Skip keypairs that are still in use. + if _, inUse := usedKeys[kp.Name]; inUse { + continue + } + + // Retrieve detailed information in order to obtain the optional + // `created_at` field. + var detail struct { + Keypair struct { + CreatedAt string `json:"created_at"` + } `json:"keypair"` + } + + if err := keypairs.Get(context.TODO(), a.computeClient, kp.Name, nil).ExtractInto(&detail); err != nil { + // If we fail to obtain details, skip deletion to be safe. + plog.Warningf("could not get details for keypair %s: %v", kp.Name, err) + continue + } + + if detail.Keypair.CreatedAt == "" { + // Missing creation timestamp – skip. + continue + } + + // Attempt to parse the timestamp using a small set of common layouts. + var createdTime time.Time + var parseErr error + for _, layout := range []string{ + time.RFC3339, + "2006-01-02T15:04:05.999999Z07:00", // micro-seconds + TZ + "2006-01-02T15:04:05.999999", // micro-seconds, no TZ + "2006-01-02T15:04:05", // seconds, no TZ + } { + createdTime, parseErr = time.Parse(layout, detail.Keypair.CreatedAt) + if parseErr == nil { + // If the layout did not specify timezone information (no "Z07"), + // assume the timestamp is in UTC, which is what OpenStack typically + // uses internally. + if !strings.Contains(layout, "Z07") { + createdTime = createdTime.UTC() + } + break + } + } + if parseErr != nil { + plog.Warningf("unable to parse created_at for keypair %s: %v", kp.Name, parseErr) + continue + } + + if now.Sub(createdTime) > olderThan { + if err := a.DeleteKey(kp.Name); err != nil { + plog.Warningf("failed deleting stale keypair %s: %v", kp.Name, err) + } else { + plog.Infof("deleted stale keypair %s (age %s)", kp.Name, now.Sub(createdTime)) + } + } else { + plog.Infof("skipping keypair %s (age %s)", kp.Name, now.Sub(createdTime)) + } + } + + return nil +} + +func (a *API) AddKey(name, key string) error { + _, err := keypairs.Create(context.TODO(), a.computeClient, keypairs.CreateOpts{ + Name: name, + PublicKey: key, + }).Extract() + return err +} + +func (a *API) DeleteKey(name string) error { + return keypairs.Delete(context.TODO(), a.computeClient, name, nil).ExtractErr() +} + +func (a *API) listServersWithMetadata(metadata map[string]string) ([]servers.Server, error) { + pager := servers.List(a.computeClient, servers.ListOpts{}) + + pages, err := unwrapPages(context.TODO(), pager, true) + if err != nil { + return nil, fmt.Errorf("servers: %v", err) + } + + allServers, err := servers.ExtractServers(pages) + if err != nil { + return nil, fmt.Errorf("extracting servers: %v", err) + } + var retServers []servers.Server + for _, server := range allServers { + isMatch := true + for key, val := range metadata { + if value, ok := server.Metadata[key]; !ok || val != value { + isMatch = false + break + } + } + if isMatch { + retServers = append(retServers, server) + } + } + return retServers, nil +} + +func (a *API) listImagesWithTags(tags []string) ([]images.Image, error) { + listOpts := images.ListOpts{ + Tags: tags, + } + + allPages, err := images.List(a.imageClient, listOpts).AllPages(context.TODO()) + if err != nil { + return nil, fmt.Errorf("listing images: %w", err) + } + + allImages, err := images.ExtractImages(allPages) + if err != nil { + return nil, fmt.Errorf("extracting image list: %w", err) + } + + return allImages, nil +} + +func (a *API) GC(gracePeriod time.Duration) error { + threshold := time.Now().Add(-gracePeriod) + + servers, err := a.listServersWithMetadata(map[string]string{ + "CreatedBy": "mantle", + }) + if err != nil { + return err + } + for _, server := range servers { + if strings.Contains(server.Status, "DELETED") || server.Created.After(threshold) { + continue + } + + if err := a.DeleteServer(server.ID); err != nil { + return fmt.Errorf("couldn't delete server %s: %v", server.ID, err) + } + } + + images, err := a.listImagesWithTags([]string{"mantle"}) + if err != nil { + return fmt.Errorf("listing Mantle images: %w", err) + } + + for _, image := range images { + if image.CreatedAt.After(threshold) { + continue + } + + if err := a.DeleteImage(image.ID); err != nil { + return fmt.Errorf("deleting image with name: %s", image.Name) + } + } + + err = a.PruneKeys(gracePeriod) + if err != nil { + return fmt.Errorf("pruning keys: %v", err) + } + + return nil +}