feat(coderd): add provisioner build version and api_version on serve (#11369)

* assert provisioner daemon version and api_version in unit tests
* add build info in HTTP header, extract codersdk.BuildVersionHeader
* add api_version to codersdk.ProvisionerDaemon
* testutil.MustString -> testutil.MustRandString
This commit is contained in:
Cian Johnston 2024-01-03 09:01:57 +00:00 committed by GitHub
parent 9031b498ea
commit 1ef96022b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 97 additions and 34 deletions

View File

@ -16,6 +16,7 @@ import (
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
)
@ -58,7 +59,7 @@ func TestLogin(t *testing.T) {
t.Parallel()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Coder-Build-Version", "something")
w.Header().Set(codersdk.BuildVersionHeader, "something")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("Not Found"))
}))

3
coderd/apidoc/docs.go generated
View File

@ -10052,6 +10052,9 @@ const docTemplate = `{
"codersdk.ProvisionerDaemon": {
"type": "object",
"properties": {
"api_version": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"

View File

@ -9036,6 +9036,9 @@
"codersdk.ProvisionerDaemon": {
"type": "object",
"properties": {
"api_version": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"

View File

@ -564,7 +564,7 @@ func New(options *Options) *API {
// Build-Version is helpful for debugging.
func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("X-Coder-Build-Version", buildinfo.Version())
w.Header().Add(codersdk.BuildVersionHeader, buildinfo.Version())
next.ServeHTTP(w, r)
})
},
@ -1194,7 +1194,7 @@ func (api *API) CreateInMemoryProvisionerDaemon(ctx context.Context, name string
Tags: provisionersdk.MutateTags(uuid.Nil, nil),
LastSeenAt: sql.NullTime{Time: dbtime.Now(), Valid: true},
Version: buildinfo.Version(),
APIVersion: "1.0",
APIVersion: provisionersdk.APIVersionCurrent,
})
if err != nil {
return nil, xerrors.Errorf("failed to create in-memory provisioner daemon: %w", err)

View File

@ -7279,6 +7279,7 @@ func (q *FakeQuerier) UpsertProvisionerDaemon(_ context.Context, arg database.Up
ReplicaID: uuid.NullUUID{},
LastSeenAt: arg.LastSeenAt,
Version: arg.Version,
APIVersion: arg.APIVersion,
}
q.provisionerDaemons = append(q.provisionerDaemons, d)
return d, nil

View File

@ -218,7 +218,7 @@ func TestDeleteOldProvisionerDaemons(t *testing.T) {
CreatedAt: now.Add(-14 * 24 * time.Hour),
LastSeenAt: sql.NullTime{Valid: true, Time: now.Add(-7 * 24 * time.Hour).Add(time.Minute)},
Version: "1.0.0",
APIVersion: "1.0",
APIVersion: provisionersdk.APIVersionCurrent,
})
require.NoError(t, err)
_, err = db.UpsertProvisionerDaemon(ctx, database.UpsertProvisionerDaemonParams{
@ -229,7 +229,7 @@ func TestDeleteOldProvisionerDaemons(t *testing.T) {
CreatedAt: now.Add(-8 * 24 * time.Hour),
LastSeenAt: sql.NullTime{Valid: true, Time: now.Add(-8 * 24 * time.Hour).Add(time.Hour)},
Version: "1.0.0",
APIVersion: "1.0",
APIVersion: provisionersdk.APIVersionCurrent,
})
require.NoError(t, err)
_, err = db.UpsertProvisionerDaemon(ctx, database.UpsertProvisionerDaemonParams{
@ -242,7 +242,7 @@ func TestDeleteOldProvisionerDaemons(t *testing.T) {
},
CreatedAt: now.Add(-9 * 24 * time.Hour),
Version: "1.0.0",
APIVersion: "1.0",
APIVersion: provisionersdk.APIVersionCurrent,
})
require.NoError(t, err)
_, err = db.UpsertProvisionerDaemon(ctx, database.UpsertProvisionerDaemonParams{
@ -256,7 +256,7 @@ func TestDeleteOldProvisionerDaemons(t *testing.T) {
CreatedAt: now.Add(-6 * 24 * time.Hour),
LastSeenAt: sql.NullTime{Valid: true, Time: now.Add(-6 * 24 * time.Hour)},
Version: "1.0.0",
APIVersion: "1.0",
APIVersion: provisionersdk.APIVersionCurrent,
})
require.NoError(t, err)

View File

@ -24,6 +24,7 @@ import (
"golang.org/x/oauth2"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
@ -1784,8 +1785,8 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho},
Tags: database.StringMap{},
LastSeenAt: sql.NullTime{},
Version: "",
APIVersion: "1.0",
Version: buildinfo.Version(),
APIVersion: provisionersdk.APIVersionCurrent,
})
require.NoError(t, err)

View File

@ -78,6 +78,9 @@ const (
// ProvisionerDaemonPSK contains the authentication pre-shared key for an external provisioner daemon
ProvisionerDaemonPSK = "Coder-Provisioner-Daemon-PSK"
// BuildVersionHeader contains build information of Coder.
BuildVersionHeader = "X-Coder-Build-Version"
)
// loggableMimeTypes is a list of MIME types that are safe to log

View File

@ -15,6 +15,7 @@ import (
"golang.org/x/xerrors"
"nhooyr.io/websocket"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/codersdk/drpc"
"github.com/coder/coder/v2/provisionerd/proto"
"github.com/coder/coder/v2/provisionerd/runner"
@ -41,6 +42,7 @@ type ProvisionerDaemon struct {
LastSeenAt NullTime `json:"last_seen_at,omitempty" format:"date-time"`
Name string `json:"name"`
Version string `json:"version"`
APIVersion string `json:"api_version"`
Provisioners []ProvisionerType `json:"provisioners"`
Tags map[string]string `json:"tags"`
}
@ -212,6 +214,7 @@ func (c *Client) ServeProvisionerDaemon(ctx context.Context, req ServeProvisione
}
headers := http.Header{}
headers.Set(BuildVersionHeader, buildinfo.Version())
if req.PreSharedKey == "" {
// use session token if we don't have a PSK.
jar, err := cookiejar.New(nil)

View File

@ -203,7 +203,7 @@ func (c *Client) HasFirstUser(ctx context.Context) (bool, error) {
if res.StatusCode == http.StatusNotFound {
// ensure we are talking to coder and not
// some other service that returns 404
v := res.Header.Get("X-Coder-Build-Version")
v := res.Header.Get(BuildVersionHeader)
if v == "" {
return false, xerrors.Errorf("missing build version header, not a coder instance")
}

View File

@ -1051,6 +1051,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/provisi
```json
[
{
"api_version": "string",
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
@ -1078,6 +1079,7 @@ Status Code **200**
| Name | Type | Required | Restrictions | Description |
| ------------------- | ----------------- | -------- | ------------ | ----------- |
| `[array item]` | array | false | | |
| `» api_version` | string | false | | |
| `» created_at` | string(date-time) | false | | |
| `» id` | string(uuid) | false | | |
| `» last_seen_at` | string(date-time) | false | | |

2
docs/api/schemas.md generated
View File

@ -3902,6 +3902,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
```json
{
"api_version": "string",
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
@ -3919,6 +3920,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| Name | Type | Required | Restrictions | Description |
| ------------------ | --------------- | -------- | ------------ | ----------- |
| `api_version` | string | false | | |
| `created_at` | string | false | | |
| `id` | string | false | | |
| `last_seen_at` | string | false | | |

View File

@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/rbac"
@ -49,6 +50,8 @@ func TestProvisionerDaemon_PSK(t *testing.T) {
}, testutil.WaitShort, testutil.IntervalSlow)
require.Equal(t, "matt-daemon", daemons[0].Name)
require.Equal(t, provisionersdk.ScopeOrganization, daemons[0].Tags[provisionersdk.TagScope])
require.Equal(t, buildinfo.Version(), daemons[0].Version)
require.Equal(t, provisionersdk.APIVersionCurrent, daemons[0].APIVersion)
}
func TestProvisionerDaemon_SessionToken(t *testing.T) {
@ -84,6 +87,8 @@ func TestProvisionerDaemon_SessionToken(t *testing.T) {
assert.Equal(t, "my-daemon", daemons[0].Name)
assert.Equal(t, provisionersdk.ScopeUser, daemons[0].Tags[provisionersdk.TagScope])
assert.Equal(t, anotherUser.ID.String(), daemons[0].Tags[provisionersdk.TagOwner])
assert.Equal(t, buildinfo.Version(), daemons[0].Version)
assert.Equal(t, provisionersdk.APIVersionCurrent, daemons[0].APIVersion)
})
t.Run("ScopeAnotherUser", func(t *testing.T) {
@ -118,6 +123,8 @@ func TestProvisionerDaemon_SessionToken(t *testing.T) {
assert.Equal(t, provisionersdk.ScopeUser, daemons[0].Tags[provisionersdk.TagScope])
// This should get clobbered to the user who started the daemon.
assert.Equal(t, anotherUser.ID.String(), daemons[0].Tags[provisionersdk.TagOwner])
assert.Equal(t, buildinfo.Version(), daemons[0].Version)
assert.Equal(t, provisionersdk.APIVersionCurrent, daemons[0].APIVersion)
})
t.Run("ScopeOrg", func(t *testing.T) {
@ -150,5 +157,7 @@ func TestProvisionerDaemon_SessionToken(t *testing.T) {
}, testutil.WaitShort, testutil.IntervalSlow)
assert.Equal(t, "org-daemon", daemons[0].Name)
assert.Equal(t, provisionersdk.ScopeOrganization, daemons[0].Tags[provisionersdk.TagScope])
assert.Equal(t, buildinfo.Version(), daemons[0].Version)
assert.Equal(t, provisionersdk.APIVersionCurrent, daemons[0].APIVersion)
})
}

View File

@ -15,9 +15,9 @@ import (
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/postgres"
"github.com/coder/coder/v2/cryptorand"
"github.com/coder/coder/v2/enterprise/dbcrypt"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
// TestServerDBCrypt tests end-to-end encryption, decryption, and deletion
@ -50,7 +50,7 @@ func TestServerDBCrypt(t *testing.T) {
users := genData(t, db)
// Setup an initial cipher A
keyA := mustString(t, 32)
keyA := testutil.MustRandString(t, 32)
cipherA, err := dbcrypt.NewCiphers([]byte(keyA))
require.NoError(t, err)
@ -87,7 +87,7 @@ func TestServerDBCrypt(t *testing.T) {
}
// Re-encrypt all existing data with a new cipher.
keyB := mustString(t, 32)
keyB := testutil.MustRandString(t, 32)
cipherBA, err := dbcrypt.NewCiphers([]byte(keyB), []byte(keyA))
require.NoError(t, err)
@ -160,7 +160,7 @@ func TestServerDBCrypt(t *testing.T) {
}
// Re-encrypt all existing data with a new cipher.
keyC := mustString(t, 32)
keyC := testutil.MustRandString(t, 32)
cipherC, err := dbcrypt.NewCiphers([]byte(keyC))
require.NoError(t, err)
@ -222,7 +222,7 @@ func genData(t *testing.T, db database.Store) []database.User {
for _, status := range database.AllUserStatusValues() {
for _, loginType := range database.AllLoginTypeValues() {
for _, deleted := range []bool{false, true} {
randName := mustString(t, 32)
randName := testutil.MustRandString(t, 32)
usr := dbgen.User(t, db, database.User{
Username: randName,
Email: randName + "@notcoder.com",
@ -252,13 +252,6 @@ func genData(t *testing.T, db database.Store) []database.User {
return users
}
func mustString(t *testing.T, n int) string {
t.Helper()
s, err := cryptorand.String(n)
require.NoError(t, err)
return s
}
func requireEncryptedEquals(t *testing.T, c dbcrypt.Cipher, expected, actual string) {
t.Helper()
var decodedVal []byte

View File

@ -233,6 +233,8 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request)
authCtx = dbauthz.AsSystemRestricted(ctx)
}
versionHdrVal := r.Header.Get(codersdk.BuildVersionHeader)
// Create the daemon in the database.
now := dbtime.Now()
daemon, err := api.Database.UpsertProvisionerDaemon(authCtx, database.UpsertProvisionerDaemonParams{
@ -241,8 +243,8 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request)
Tags: tags,
CreatedAt: now,
LastSeenAt: sql.NullTime{Time: now, Valid: true},
Version: "", // TODO: provisionerd needs to send version
APIVersion: "1.0",
Version: versionHdrVal,
APIVersion: provisionersdk.APIVersionCurrent,
})
if err != nil {
if !xerrors.Is(err, context.Canceled) {
@ -361,6 +363,7 @@ func convertProvisionerDaemon(daemon database.ProvisionerDaemon) codersdk.Provis
Name: daemon.Name,
Tags: daemon.Tags,
Version: daemon.Version,
APIVersion: daemon.APIVersion,
}
for _, provisionerType := range daemon.Provisioners {
result.Provisioners = append(result.Provisioners, codersdk.ProvisionerType(provisionerType))

View File

@ -12,6 +12,7 @@ import (
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/rbac"
@ -40,9 +41,10 @@ func TestProvisionerDaemonServe(t *testing.T) {
templateAdminClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleTemplateAdmin())
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
daemonName := testutil.MustRandString(t, 63)
srv, err := templateAdminClient.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{
ID: uuid.New(),
Name: t.Name(),
Name: daemonName,
Organization: user.OrganizationID,
Provisioners: []codersdk.ProvisionerType{
codersdk.ProvisionerTypeEcho,
@ -54,7 +56,11 @@ func TestProvisionerDaemonServe(t *testing.T) {
daemons, err := client.ProvisionerDaemons(ctx) //nolint:gocritic // Test assertion.
require.NoError(t, err)
require.Len(t, daemons, 1)
if assert.Len(t, daemons, 1) {
assert.Equal(t, daemonName, daemons[0].Name)
assert.Equal(t, buildinfo.Version(), daemons[0].Version)
assert.Equal(t, provisionersdk.APIVersionCurrent, daemons[0].APIVersion)
}
})
t.Run("NoLicense", func(t *testing.T) {
@ -63,9 +69,10 @@ func TestProvisionerDaemonServe(t *testing.T) {
templateAdminClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleTemplateAdmin())
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
daemonName := testutil.MustRandString(t, 63)
_, err := templateAdminClient.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{
ID: uuid.New(),
Name: t.Name(),
Name: daemonName,
Organization: user.OrganizationID,
Provisioners: []codersdk.ProvisionerType{
codersdk.ProvisionerTypeEcho,
@ -90,7 +97,7 @@ func TestProvisionerDaemonServe(t *testing.T) {
defer cancel()
_, err := another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{
ID: uuid.New(),
Name: t.Name(),
Name: testutil.MustRandString(t, 63),
Organization: user.OrganizationID,
Provisioners: []codersdk.ProvisionerType{
codersdk.ProvisionerTypeEcho,
@ -117,7 +124,7 @@ func TestProvisionerDaemonServe(t *testing.T) {
defer cancel()
_, err := another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{
ID: uuid.New(),
Name: t.Name(),
Name: testutil.MustRandString(t, 63),
Organization: user.OrganizationID,
Provisioners: []codersdk.ProvisionerType{
codersdk.ProvisionerTypeEcho,
@ -212,7 +219,9 @@ func TestProvisionerDaemonServe(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
another := codersdk.New(client.URL)
daemonName := testutil.MustRandString(t, 63)
srv, err := another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{
Name: daemonName,
Organization: user.OrganizationID,
Provisioners: []codersdk.ProvisionerType{
codersdk.ProvisionerTypeEcho,
@ -229,6 +238,7 @@ func TestProvisionerDaemonServe(t *testing.T) {
daemons, err := client.ProvisionerDaemons(ctx) //nolint:gocritic // Test assertion.
require.NoError(t, err)
if assert.Len(t, daemons, 1) {
assert.Equal(t, daemonName, daemons[0].Name)
assert.Equal(t, provisionersdk.ScopeOrganization, daemons[0].Tags[provisionersdk.TagScope])
}
})
@ -274,7 +284,7 @@ func TestProvisionerDaemonServe(t *testing.T) {
pd := provisionerd.New(func(ctx context.Context) (provisionerdproto.DRPCProvisionerDaemonClient, error) {
return another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{
ID: uuid.New(),
Name: t.Name(),
Name: testutil.MustRandString(t, 63),
Organization: user.OrganizationID,
Provisioners: []codersdk.ProvisionerType{
codersdk.ProvisionerTypeEcho,
@ -352,7 +362,7 @@ func TestProvisionerDaemonServe(t *testing.T) {
defer cancel()
_, err := another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{
ID: uuid.New(),
Name: t.Name(),
Name: testutil.MustRandString(t, 32),
Organization: user.OrganizationID,
Provisioners: []codersdk.ProvisionerType{
codersdk.ProvisionerTypeEcho,
@ -387,7 +397,7 @@ func TestProvisionerDaemonServe(t *testing.T) {
another := codersdk.New(client.URL)
_, err := another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{
ID: uuid.New(),
Name: t.Name(),
Name: testutil.MustRandString(t, 63),
Organization: user.OrganizationID,
Provisioners: []codersdk.ProvisionerType{
codersdk.ProvisionerTypeEcho,
@ -420,7 +430,7 @@ func TestProvisionerDaemonServe(t *testing.T) {
another := codersdk.New(client.URL)
_, err := another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{
ID: uuid.New(),
Name: t.Name(),
Name: testutil.MustRandString(t, 63),
Organization: user.OrganizationID,
Provisioners: []codersdk.ProvisionerType{
codersdk.ProvisionerTypeEcho,

View File

@ -323,7 +323,7 @@ func New(ctx context.Context, opts *Options) (*Server, error) {
// Build-Version is helpful for debugging.
func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("X-Coder-Build-Version", buildinfo.Version())
w.Header().Add(codersdk.BuildVersionHeader, buildinfo.Version())
next.ServeHTTP(w, r)
})
},

View File

@ -20,6 +20,13 @@ import (
"github.com/coder/coder/v2/provisionersdk/proto"
)
const (
// APIVersionCurrent is the current provisionerd API version.
// Breaking changes to the provisionerd API **MUST** increment
// the major version below.
APIVersionCurrent = "1.0"
)
// ServeOptions are configurations to serve a provisioner.
type ServeOptions struct {
// Listener serves multiple connections. Cannot be combined with Conn.

View File

@ -79,6 +79,7 @@ export const provisioners: TypesGen.ProvisionerDaemon[] = [
provisioners: [],
tags: {},
version: "v2.34.5",
api_version: "1.0",
},
{
id: "cdr-basic",
@ -87,6 +88,7 @@ export const provisioners: TypesGen.ProvisionerDaemon[] = [
provisioners: [],
tags: {},
version: "v2.34.5",
api_version: "1.0",
},
];

View File

@ -809,6 +809,7 @@ export interface ProvisionerDaemon {
readonly last_seen_at?: string;
readonly name: string;
readonly version: string;
readonly api_version: string;
readonly provisioners: ProvisionerType[];
readonly tags: Record<string, string>;
}

View File

@ -336,6 +336,7 @@ export const MockProvisioner: TypesGen.ProvisionerDaemon = {
provisioners: ["echo"],
tags: { scope: "organization" },
version: "v2.34.5",
api_version: "1.0",
};
export const MockUserProvisioner: TypesGen.ProvisionerDaemon = {
@ -345,6 +346,7 @@ export const MockUserProvisioner: TypesGen.ProvisionerDaemon = {
provisioners: ["echo"],
tags: { scope: "user", owner: "12345678-abcd-1234-abcd-1234567890abcd" },
version: "v2.34.5",
api_version: "1.0",
};
export const MockProvisionerJob: TypesGen.ProvisionerJob = {

17
testutil/rand.go Normal file
View File

@ -0,0 +1,17 @@
package testutil
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cryptorand"
)
// MustRandString returns a random string of length n.
func MustRandString(t *testing.T, n int) string {
t.Helper()
s, err := cryptorand.String(n)
require.NoError(t, err)
return s
}