diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2ca7083..4be2b0c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,6 +1,6 @@ # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. # -# Generated on 2023-11-28T14:31:58Z by kres latest. +# Generated on 2023-11-30T12:42:01Z by kres latest. name: default concurrency: diff --git a/.github/workflows/slack-notify.yaml b/.github/workflows/slack-notify.yaml index dd898b7..0507f39 100644 --- a/.github/workflows/slack-notify.yaml +++ b/.github/workflows/slack-notify.yaml @@ -1,6 +1,6 @@ # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. # -# Generated on 2023-11-21T16:57:47Z by kres latest. +# Generated on 2023-11-30T12:42:01Z by kres latest. name: slack-notify "on": diff --git a/Dockerfile b/Dockerfile index 51d8080..b38f480 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. # -# Generated on 2023-11-30T10:18:19Z by kres 902f3bd-dirty. +# Generated on 2023-11-30T12:42:01Z by kres latest. ARG TOOLCHAIN diff --git a/Makefile b/Makefile index 8dbb459..d85de88 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. # -# Generated on 2023-11-30T10:20:41Z by kres 902f3bd-dirty. +# Generated on 2023-11-30T12:36:19Z by kres latest. # common variables diff --git a/internal/frontend/http/meta.go b/internal/frontend/http/meta.go index 6cb3d9b..a610481 100644 --- a/internal/frontend/http/meta.go +++ b/internal/frontend/http/meta.go @@ -16,6 +16,7 @@ import ( "github.com/siderolabs/gen/xslices" "github.com/siderolabs/image-factory/internal/artifacts" + "github.com/siderolabs/image-factory/pkg/client" ) // handleVersions handles list of Talos versions available. @@ -49,15 +50,9 @@ func (f *Frontend) handleOfficialExtensions(ctx context.Context, w http.Response return err } - type extensionInfo struct { - Name string `json:"name"` - Ref string `json:"ref"` - Digest string `json:"digest"` - } - return json.NewEncoder(w).Encode( - xslices.Map(extensions, func(e artifacts.ExtensionRef) extensionInfo { - return extensionInfo{ + xslices.Map(extensions, func(e artifacts.ExtensionRef) client.ExtensionInfo { + return client.ExtensionInfo{ Name: e.TaggedReference.RepositoryStr(), Ref: e.TaggedReference.String(), Digest: e.Digest, diff --git a/internal/integration/meta_test.go b/internal/integration/meta_test.go index e307a35..37813dd 100644 --- a/internal/integration/meta_test.go +++ b/internal/integration/meta_test.go @@ -8,70 +8,41 @@ package integration_test import ( "context" - "encoding/json" - "net/http" "testing" "github.com/siderolabs/gen/xslices" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/siderolabs/image-factory/pkg/client" ) -func getVersions(ctx context.Context, t *testing.T, baseURL string) []string { +func getVersions(ctx context.Context, t *testing.T, c *client.Client) []string { t.Helper() - req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/versions", nil) - require.NoError(t, err) - - resp, err := http.DefaultClient.Do(req) + versions, err := c.Versions(ctx) require.NoError(t, err) - t.Cleanup(func() { - resp.Body.Close() - }) - - require.Equal(t, http.StatusOK, resp.StatusCode) - - var versions []string - - require.NoError(t, json.NewDecoder(resp.Body).Decode(&versions)) - return versions } -type extensionInfo struct { - Name string `json:"name"` - Ref string `json:"ref"` - Digest string `json:"digest"` -} - -func getExtensions(ctx context.Context, t *testing.T, baseURL, talosVersion string) []extensionInfo { +func getExtensions(ctx context.Context, t *testing.T, c *client.Client, talosVersion string) []client.ExtensionInfo { t.Helper() - req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/version/"+talosVersion+"/extensions/official", nil) + versions, err := c.ExtensionsVersions(ctx, talosVersion) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - - t.Cleanup(func() { - resp.Body.Close() - }) - - require.Equal(t, http.StatusOK, resp.StatusCode) - - var extensions []extensionInfo - - require.NoError(t, json.NewDecoder(resp.Body).Decode(&extensions)) - - return extensions + return versions } func testMetaFrontend(ctx context.Context, t *testing.T, baseURL string) { + c, err := client.New(baseURL) + require.NoError(t, err) + t.Run("versions", func(t *testing.T) { t.Parallel() - versions := getVersions(ctx, t, baseURL) + versions := getVersions(ctx, t, c) assert.Greater(t, len(versions), 10) }) @@ -88,9 +59,9 @@ func testMetaFrontend(ctx context.Context, t *testing.T, baseURL string) { t.Run(talosVersion, func(t *testing.T) { t.Parallel() - extensions := getExtensions(ctx, t, baseURL, talosVersion) + extensions := getExtensions(ctx, t, c, talosVersion) - names := xslices.Map(extensions, func(ext extensionInfo) string { + names := xslices.Map(extensions, func(ext client.ExtensionInfo) string { return ext.Name }) diff --git a/internal/integration/registry_test.go b/internal/integration/registry_test.go index f512df1..e907ee3 100644 --- a/internal/integration/registry_test.go +++ b/internal/integration/registry_test.go @@ -31,6 +31,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" + "github.com/siderolabs/image-factory/pkg/client" "github.com/siderolabs/image-factory/pkg/schematic" ) @@ -181,10 +182,13 @@ func testRegistryFrontend(ctx context.Context, t *testing.T, registryAddr string registry, err := name.NewRegistry(registryAddr) require.NoError(t, err) + c, err := client.New("http://" + registryAddr) + require.NoError(t, err) + // create a new random schematic, so that we can make sure new installer is generated randomKernelArg := hex.EncodeToString(randomBytes(t, 32)) - randomSchematicID := createSchematicGetID(ctx, t, "http://"+registryAddr, + randomSchematicID := createSchematicGetID(ctx, t, c, schematic.Schematic{ Customization: schematic.Customization{ ExtraKernelArgs: []string{randomKernelArg}, diff --git a/internal/integration/schematic_test.go b/internal/integration/schematic_test.go index db86b09..b898fda 100644 --- a/internal/integration/schematic_test.go +++ b/internal/integration/schematic_test.go @@ -11,7 +11,6 @@ import ( "context" "crypto/rand" "encoding/hex" - "encoding/json" "io" "net/http" "testing" @@ -19,6 +18,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/siderolabs/image-factory/pkg/client" "github.com/siderolabs/image-factory/pkg/schematic" ) @@ -30,47 +30,28 @@ const ( metaSchematicID = "fe866116408a5a13dab7d5003eb57a00954ea81ebeec3fbbcd1a6d4462a00036" ) -func createSchematic(ctx context.Context, t *testing.T, baseURL string, marshalled []byte) *http.Response { +func createSchematicGetID(ctx context.Context, t *testing.T, c *client.Client, schematic schematic.Schematic) string { t.Helper() - req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/schematics", bytes.NewReader(marshalled)) - require.NoError(t, err) - - resp, err := http.DefaultClient.Do(req) + id, err := c.SchematicCreate(ctx, schematic) require.NoError(t, err) - t.Cleanup(func() { - resp.Body.Close() - }) - - return resp + return id } -func createSchematicGetID(ctx context.Context, t *testing.T, baseURL string, schematic schematic.Schematic) string { +// not using the client here as we need to submit invalid yaml. +func createSchematicInvalid(ctx context.Context, t *testing.T, baseURL string, marshalled []byte) string { t.Helper() - marshalled, err := schematic.Marshal() + req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/schematics", bytes.NewReader(marshalled)) require.NoError(t, err) - resp := createSchematic(ctx, t, baseURL, marshalled) - - require.Equal(t, http.StatusCreated, resp.StatusCode) - - var respBody struct { - ID string `json:"id"` - } - - require.NoError(t, json.NewDecoder(resp.Body).Decode(&respBody)) - - return respBody.ID -} - -func createSchematicInvalid(ctx context.Context, t *testing.T, baseURL string, marshalled []byte) string { - t.Helper() - - resp := createSchematic(ctx, t, baseURL, marshalled) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) - require.Equal(t, http.StatusBadRequest, resp.StatusCode) + t.Cleanup(func() { + resp.Body.Close() + }) body, err := io.ReadAll(resp.Body) require.NoError(t, err) @@ -79,12 +60,15 @@ func createSchematicInvalid(ctx context.Context, t *testing.T, baseURL string, m } func testSchematic(ctx context.Context, t *testing.T, baseURL string) { + c, err := client.New(baseURL) + require.NoError(t, err) + t.Run("empty", func(t *testing.T) { - assert.Equal(t, emptySchematicID, createSchematicGetID(ctx, t, baseURL, schematic.Schematic{})) + assert.Equal(t, emptySchematicID, createSchematicGetID(ctx, t, c, schematic.Schematic{})) }) t.Run("kernel args", func(t *testing.T) { - assert.Equal(t, extraArgsSchematicID, createSchematicGetID(ctx, t, baseURL, + assert.Equal(t, extraArgsSchematicID, createSchematicGetID(ctx, t, c, schematic.Schematic{ Customization: schematic.Customization{ ExtraKernelArgs: []string{"nolapic", "nomodeset"}, @@ -94,7 +78,7 @@ func testSchematic(ctx context.Context, t *testing.T, baseURL string) { }) t.Run("system extensions", func(t *testing.T) { - assert.Equal(t, systemExtensionsSchematicID, createSchematicGetID(ctx, t, baseURL, + assert.Equal(t, systemExtensionsSchematicID, createSchematicGetID(ctx, t, c, schematic.Schematic{ Customization: schematic.Customization{ SystemExtensions: schematic.SystemExtensions{ @@ -110,7 +94,7 @@ func testSchematic(ctx context.Context, t *testing.T, baseURL string) { }) t.Run("meta", func(t *testing.T) { - assert.Equal(t, metaSchematicID, createSchematicGetID(ctx, t, baseURL, + assert.Equal(t, metaSchematicID, createSchematicGetID(ctx, t, c, schematic.Schematic{ Customization: schematic.Customization{ Meta: []schematic.MetaValue{ @@ -125,7 +109,7 @@ func testSchematic(ctx context.Context, t *testing.T, baseURL string) { }) t.Run("empty once again", func(t *testing.T) { - assert.Equal(t, emptySchematicID, createSchematicGetID(ctx, t, baseURL, schematic.Schematic{})) + assert.Equal(t, emptySchematicID, createSchematicGetID(ctx, t, c, schematic.Schematic{})) }) t.Run("invalid", func(t *testing.T) { @@ -136,7 +120,7 @@ func testSchematic(ctx context.Context, t *testing.T, baseURL string) { // create a new random schematic, as the schematic is persisted, and we want to test uploading new config randomKernelArg := hex.EncodeToString(randomBytes(t, 32)) - assert.Len(t, createSchematicGetID(ctx, t, baseURL, + assert.Len(t, createSchematicGetID(ctx, t, c, schematic.Schematic{ Customization: schematic.Customization{ ExtraKernelArgs: []string{randomKernelArg}, diff --git a/pkg/client/client.go b/pkg/client/client.go new file mode 100644 index 0000000..7389820 --- /dev/null +++ b/pkg/client/client.go @@ -0,0 +1,162 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Package client implements image factory HTTP API client. +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/siderolabs/gen/xerrors" + + "github.com/siderolabs/image-factory/pkg/schematic" +) + +// InvalidSchematicError is parsed from 400 response from the server. +type InvalidSchematicError struct { + Details string +} + +// Error implements error interface. +func (e *InvalidSchematicError) Error() string { + return fmt.Sprintf("invalid schematic: %s", e.Details) +} + +// IsInvalidSchematicError checks if the error is invalid schematic. +func IsInvalidSchematicError(err error) bool { + return xerrors.TypeIs[*InvalidSchematicError](err) +} + +// ExtensionInfo defines extensions versions list response item. +type ExtensionInfo struct { + Name string `json:"name"` + Ref string `json:"ref"` + Digest string `json:"digest"` +} + +// Client is the Image Factory HTTP API client. +type Client struct { + baseURL *url.URL + client http.Client +} + +// New creates a new Image Factory API client. +func New(baseURL string, options ...Option) (*Client, error) { + opts := withDefaults(options) + + bURL, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + + c := &Client{ + baseURL: bURL, + client: opts.Client, + } + + return c, nil +} + +// SchematicCreate generates new schematic from the configuration. +func (c *Client) SchematicCreate(ctx context.Context, schematic schematic.Schematic) (string, error) { + data, err := schematic.Marshal() + if err != nil { + return "", err + } + + var response struct { + ID string `json:"id"` + } + + if err = c.do(ctx, http.MethodPost, "/schematics", data, &response, map[string]string{ + "Content-Type": "application/yaml", + }); err != nil { + return "", err + } + + return response.ID, nil +} + +// Versions gets the list of Talos versions available. +func (c *Client) Versions(ctx context.Context) ([]string, error) { + var versions []string + + if err := c.do(ctx, http.MethodGet, "/versions", nil, &versions, nil); err != nil { + return nil, err + } + + return versions, nil +} + +// ExtensionsVersions gets the version of the extension for a Talos version. +func (c *Client) ExtensionsVersions(ctx context.Context, talosVersion string) ([]ExtensionInfo, error) { + var versions []ExtensionInfo + + if err := c.do(ctx, http.MethodGet, fmt.Sprintf("/version/%s/extensions/official", talosVersion), nil, &versions, nil); err != nil { + return nil, err + } + + return versions, nil +} + +func (c *Client) do(ctx context.Context, method, uri string, requestData []byte, responseData any, headers map[string]string) error { + var reader io.Reader + + if requestData != nil { + reader = bytes.NewReader(requestData) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL.JoinPath(uri).String(), reader) + if err != nil { + return err + } + + for k, v := range headers { + req.Header.Add(k, v) + } + + resp, err := c.client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() //nolint:errcheck + + if err = c.checkError(resp); err != nil { + return err + } + + if responseData != nil { + decoder := json.NewDecoder(resp.Body) + + return decoder.Decode(responseData) + } + + return nil +} + +func (c *Client) checkError(resp *http.Response) error { + if resp.StatusCode == http.StatusBadRequest { + details, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + return &InvalidSchematicError{ + Details: string(details), + } + } + + if resp.StatusCode >= http.StatusBadRequest { + return fmt.Errorf("request failed, code %d", resp.StatusCode) + } + + return nil +} diff --git a/pkg/client/options.go b/pkg/client/options.go new file mode 100644 index 0000000..9d223e5 --- /dev/null +++ b/pkg/client/options.go @@ -0,0 +1,34 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Package client implements image factory HTTP API client. +package client + +import "net/http" + +// Options defines client options. +type Options struct { + // Client is the http client. + Client http.Client +} + +// Option defines a single client option setter. +type Option func(*Options) + +// WithClient overrides default client instance. +func WithClient(client http.Client) Option { + return func(o *Options) { + o.Client = client + } +} + +func withDefaults(options []Option) *Options { + opts := &Options{} + + for _, o := range options { + o(opts) + } + + return opts +}