Skip to content

chore: color value_source for deployment values #9922

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Sep 29, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions cli/clibase/option.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package clibase

import (
"bytes"
"encoding/json"
"os"
"strings"

Expand Down Expand Up @@ -65,6 +67,20 @@ type Option struct {
ValueSource ValueSource `json:"value_source,omitempty"`
}

// optionNoMethods is just a wrapper around Option so we can defer to the
// default json.Unmarshaler behavior.
type optionNoMethods Option

func (o *Option) UnmarshalJSON(data []byte) error {
// If an option has no values, we have no idea how to unmarshal it.
// So just discard the json data.
if o.Value == nil {
o.Value = &DiscardValue
}
Comment on lines +75 to +79
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the values are behind an interface, if we do not know how to implement the .String() method, then having the value in an unknown form is pointless.

We could add some logic to try and detect primitive options, but I do not see the value. We should always have the full set of Options when we unmarshal, so we should always get the correct types and values.


return json.Unmarshal(data, (*optionNoMethods)(o))
}

func (o Option) YAMLPath() string {
if o.YAML == "" {
return ""
Expand All @@ -79,6 +95,85 @@ func (o Option) YAMLPath() string {
// OptionSet is a group of options that can be applied to a command.
type OptionSet []Option

// UnmarshalJSON implements json.Unmarshaler for OptionSets. Options have an
// interface Value type that cannot handle unmarshalling because the types cannot
// be inferred. Since it is a slice, instantiating the Options first does not
// help.
//
// However, we typically do instantiate the slice to have the correct types.
// So this unmarshaller will attempt to find the named option in the existing
// set, if it cannot, the value is discarded. If the option exists, the value
// is unmarshalled into the existing option, and replaces the existing option.
//
// The value is discarded if it's type cannot be inferred. This behavior just
// feels "safer", although it should never happen if the correct option set
// is passed in.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure under what circumstances this Unmarshal is run? Is there a chance of conflicts between server versions (API response) and CLI version (decode)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup exactly. The new version can have more deployment options, so the old version has nothing to match it against.

func (os *OptionSet) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewBuffer(data))
// Should be a json array, so consume the starting open bracket.
t, err := dec.Token()
if err != nil {
return xerrors.Errorf("read array open bracket: %w", err)
}
if t != json.Delim('[') {
return xerrors.Errorf("expected array open bracket, got %q", t)
}

// As long as json elements exist, consume them. The counter is used for
// better errors.
var i int
OptionSetDecodeLoop:
for dec.More() {
var opt Option
// jValue is a placeholder value that allows us to capture the
// raw json for the value to attempt to unmarshal later.
var jValue jsonValue
opt.Value = &jValue
err := dec.Decode(&opt)
if err != nil {
return xerrors.Errorf("decode %d option: %w", i, err)
}

// Try to see if the option already exists in the option set.
// If it does, just update the existing option.
for i, have := range *os {
if have.Name == opt.Name {
if jValue != nil {
err := json.Unmarshal(jValue, &(*os)[i].Value)
if err != nil {
return xerrors.Errorf("decode option %q value: %w", have.Name, err)
}
// Set the opt's value
opt.Value = (*os)[i].Value
} else {
// Discard the value if it's nil.
opt.Value = DiscardValue
}
// Override the existing.
(*os)[i] = opt
// Go to the next option to decode.
continue OptionSetDecodeLoop
}
}

// If the option doesn't exist, the value will be discarded.
// We do this because we cannot infer the type of the value.
opt.Value = DiscardValue
*os = append(*os, opt)
i++
}

t, err = dec.Token()
if err != nil {
return xerrors.Errorf("read array close bracket: %w", err)
}
if t != json.Delim(']') {
return xerrors.Errorf("expected array close bracket, got %q", t)
}

return nil
}

// Add adds the given Options to the OptionSet.
func (s *OptionSet) Add(opts ...Option) {
*s = append(*s, opts...)
Expand Down
29 changes: 29 additions & 0 deletions cli/clibase/values.go
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,35 @@ func (discardValue) Type() string {
return "discard"
}

func (discardValue) UnmarshalJSON(data []byte) error {
return nil
}

// jsonValue is intentionally not exported. It is just used to store the raw JSON
// data for a value to defer it's unmarshal. It implements the pflag.Value to be
// usable in an Option.
type jsonValue json.RawMessage

func (jsonValue) Set(string) error {
return xerrors.Errorf("json value is read-only")
}

func (jsonValue) String() string {
return ""
}

func (jsonValue) Type() string {
return "json"
}

func (j *jsonValue) UnmarshalJSON(data []byte) error {
if j == nil {
return xerrors.New("json.RawMessage: UnmarshalJSON on nil pointer")
}
*j = append((*j)[0:0], data...)
return nil
}

var _ pflag.Value = (*Enum)(nil)

type Enum struct {
Expand Down
4 changes: 4 additions & 0 deletions cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
MetricsCacheRefreshInterval: vals.MetricsCacheRefreshInterval.Value(),
AgentStatsRefreshInterval: vals.AgentStatRefreshInterval.Value(),
DeploymentValues: vals,
// Do not pass secret values to DeploymentOptions. All values should be read from
// the DeploymentValues instead, this just serves to indicate the source of each
// option. This is just defensive to prevent accidentally leaking.
DeploymentOptions: codersdk.DeploymentOptionsWithoutSecrets(opts),
PrometheusRegistry: prometheus.NewRegistry(),
APIRateLimit: int(vals.RateLimit.API.Value()),
LoginRateLimit: loginRateLimit,
Expand Down
16 changes: 16 additions & 0 deletions cli/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1188,6 +1188,22 @@ func TestServer(t *testing.T) {
require.Equal(t, map[string]string{"serious_business_unit": "serious_business_unit"}, deploymentConfig.Values.OIDC.GroupMapping.Value)
require.Equal(t, "Sign In With Coder", deploymentConfig.Values.OIDC.SignInText.Value())
require.Equal(t, "https://example.com/icon.png", deploymentConfig.Values.OIDC.IconURL.Value().String())

// Verify the option values
for _, opt := range deploymentConfig.Options {
switch opt.Flag {
case "access-url":
require.Equal(t, "http://example.com", opt.Value.String())
case "oidc-icon-url":
require.Equal(t, "https://example.com/icon.png", opt.Value.String())
case "oidc-sign-in-text":
require.Equal(t, "Sign In With Coder", opt.Value.String())
case "redirect-to-access-url":
require.Equal(t, "false", opt.Value.String())
case "derp-server-region-id":
require.Equal(t, "999", opt.Value.String())
}
}
})
})

Expand Down
8 changes: 7 additions & 1 deletion coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import (

"cdr.dev/slog"
"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/awsidentity"
"github.com/coder/coder/v2/coderd/batchstats"
Expand Down Expand Up @@ -152,7 +153,12 @@ type Options struct {
MetricsCacheRefreshInterval time.Duration
AgentStatsRefreshInterval time.Duration
DeploymentValues *codersdk.DeploymentValues
UpdateCheckOptions *updatecheck.Options // Set non-nil to enable update checking.
// DeploymentOptions do contain the copy of DeploymentValues, but contain
// contextual information about how the values were set.
// Do not use DeploymentOptions to retrieve values, use DeploymentValues instead.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just strip all values from DeploymentOptions then? Too many allocations?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to go this route, but the UI grabs the value from the option. Values are returned in the parsed form, and the option form:

codersdk.DeploymentConfig{
Values: values,
Options: api.DeploymentOptions,
},

There is no way to associate the values between the two, so the value needs to be present on the Option for the UI to know.

I feel like this could use a bit of a refactor, but it is working and not really that important.

// All secrets values are stripped.
DeploymentOptions clibase.OptionSet
UpdateCheckOptions *updatecheck.Options // Set non-nil to enable update checking.

// SSHConfig is the response clients use to configure config-ssh locally.
SSHConfig codersdk.SSHConfigResponse
Expand Down
1 change: 1 addition & 0 deletions coderd/coderdtest/coderdtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
MetricsCacheRefreshInterval: options.MetricsCacheRefreshInterval,
AgentStatsRefreshInterval: options.AgentStatsRefreshInterval,
DeploymentValues: options.DeploymentValues,
DeploymentOptions: codersdk.DeploymentOptionsWithoutSecrets(options.DeploymentValues.Options()),
UpdateCheckOptions: options.UpdateCheckOptions,
SwaggerEndpoint: options.SwaggerEndpoint,
AppSecurityKey: AppSecurityKey,
Expand Down
2 changes: 1 addition & 1 deletion coderd/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func (api *API) deploymentValues(rw http.ResponseWriter, r *http.Request) {
r.Context(), rw, http.StatusOK,
codersdk.DeploymentConfig{
Values: values,
Options: values.Options(),
Options: api.DeploymentOptions,
},
)
}
Expand Down
20 changes: 20 additions & 0 deletions codersdk/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -1762,6 +1762,26 @@ type LinkConfig struct {
Icon string `json:"icon" yaml:"icon"`
}

// DeploymentOptionsWithoutSecrets returns a copy of the OptionSet with secret values omitted.
func DeploymentOptionsWithoutSecrets(set clibase.OptionSet) clibase.OptionSet {
cpy := make(clibase.OptionSet, 0, len(set))
for _, opt := range set {
cpyOpt := opt
if IsSecretDeploymentOption(cpyOpt) {
switch cpyOpt.Value.Type() {
case "string-array":
var empty clibase.StringArray
cpyOpt.Value = &empty
default:
var empty clibase.String
cpyOpt.Value = &empty
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oooh this is where I broke the json.Unmarshal(). @johnstcn @mafredri

So json.Unmarshal does unmarshal in place for arrays, so all the types were working correctly. When I was zeroing the secrets, I figured a string is fine, but it is not. I should have used a nil.

So this made a clibase.String try to unmarshal into whatever the secret was, which did not work. If I instead just use a nil, then it all works 🤦‍♂️.


So I can delete all the json unmarshal stuff I wrote, but my json unmarshal handles all the edge cases that would usually just fail (see https://goplay.tools/snippet/GPxfjtnfvys).

So I can delete my unmarshal code, and if your client and version have different option sets, the call will fail. 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so if you have client version X, and we add an option in server version Y, then the unmarshal will fail when client tries to get DeploymentOptions?

Yes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather have a custom UnmarshalJSON method than a potential regression between versions :-)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should keep this complicated unmarshal logic. It is more complicated, but with testing, it is safe and only affects the codersdk.Client. If we do not have this code, then we can unintentionally just break the client, and there is nothing the client can do to resolve this since the JSON from the server is the issue at hand.

}
cpy = append(cpy, cpyOpt)
}
return cpy
}

// WithoutSecrets returns a copy of the config without secret values.
func (c *DeploymentValues) WithoutSecrets() (*DeploymentValues, error) {
var ff DeploymentValues
Expand Down
20 changes: 15 additions & 5 deletions site/src/components/DeploySettingsLayout/Option.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,21 +58,31 @@ export const OptionValue: FC<{ children?: unknown }> = ({ children }) => {
return <span className={styles.optionValue}>{JSON.stringify(children)}</span>;
};

export const OptionConfig = (props: BoxProps) => {
// OptionalConfig takes a source bool to indicate if the Option is the source of the configured value.
export const OptionConfig = ({
source,
...boxProps
}: { source?: boolean } & BoxProps) => {
return (
<Box
{...props}
{...boxProps}
sx={{
fontSize: 13,
fontFamily: MONOSPACE_FONT_FAMILY,
fontWeight: 600,
backgroundColor: (theme) => theme.palette.background.paperLight,
backgroundColor: (theme) =>
source
? theme.palette.primary.dark
: theme.palette.background.paperLight,
display: "inline-flex",
alignItems: "center",
borderRadius: 0.25,
padding: (theme) => theme.spacing(0, 1),
border: (theme) => `1px solid ${theme.palette.divider}`,
...props.sx,
border: (theme) =>
`1px solid ${
source ? theme.palette.primary.main : theme.palette.divider
}`,
...boxProps.sx,
}}
/>
);
Expand Down
8 changes: 4 additions & 4 deletions site/src/components/DeploySettingsLayout/OptionsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,25 +58,25 @@ const OptionsTable: FC<{
}}
>
{option.flag && (
<OptionConfig>
<OptionConfig source={option.value_source === "flag"}>
<OptionConfigFlag>CLI</OptionConfigFlag>
--{option.flag}
</OptionConfig>
)}
{option.flag_shorthand && (
<OptionConfig>
<OptionConfig source={option.value_source === "flag"}>
<OptionConfigFlag>CLI</OptionConfigFlag>-
{option.flag_shorthand}
</OptionConfig>
)}
{option.env && (
<OptionConfig>
<OptionConfig source={option.value_source === "env"}>
<OptionConfigFlag>ENV</OptionConfigFlag>
{option.env}
</OptionConfig>
)}
{option.yaml && (
<OptionConfig>
<OptionConfig source={option.value_source === "yaml"}>
<OptionConfigFlag>YAML</OptionConfigFlag>
{option.yaml}
</OptionConfig>
Expand Down