Compare commits
16 Commits
cleanup/qu
...
release/2.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e96d8a3a1 | ||
|
|
94e26b7116 | ||
|
|
de62750d7f | ||
|
|
9eaecf1425 | ||
|
|
580081c76f | ||
|
|
54d0575fde | ||
|
|
1c8ba51410 | ||
|
|
63155d2d0a | ||
|
|
a7f0dba4c3 | ||
|
|
049feeca76 | ||
|
|
75e7a93598 | ||
|
|
8e8dd58506 | ||
|
|
bc089f3410 | ||
|
|
b906c16b3b | ||
|
|
3a68676b84 | ||
|
|
d3b6863ae9 |
2
.github/actions/setup-go/action.yaml
vendored
2
.github/actions/setup-go/action.yaml
vendored
@@ -4,7 +4,7 @@ description: |
|
||||
inputs:
|
||||
version:
|
||||
description: "The Go version to use."
|
||||
default: "1.24.2"
|
||||
default: "1.24.6"
|
||||
use-preinstalled-go:
|
||||
description: "Whether to use preinstalled Go."
|
||||
default: "false"
|
||||
|
||||
25
.github/workflows/ci.yaml
vendored
25
.github/workflows/ci.yaml
vendored
@@ -256,8 +256,8 @@ jobs:
|
||||
pushd /tmp/proto
|
||||
curl -L -o protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v23.4/protoc-23.4-linux-x86_64.zip
|
||||
unzip protoc.zip
|
||||
cp -r ./bin/* /usr/local/bin
|
||||
cp -r ./include /usr/local/bin/include
|
||||
sudo cp -r ./bin/* /usr/local/bin
|
||||
sudo cp -r ./include /usr/local/bin/include
|
||||
popd
|
||||
|
||||
- name: make gen
|
||||
@@ -428,6 +428,11 @@ jobs:
|
||||
- name: Disable Spotlight Indexing
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
enabled=$(sudo mdutil -a -s | grep "Indexing enabled" | wc -l)
|
||||
if [ $enabled -eq 0 ]; then
|
||||
echo "Spotlight indexing is already disabled"
|
||||
exit 0
|
||||
fi
|
||||
sudo mdutil -a -i off
|
||||
sudo mdutil -X /
|
||||
sudo launchctl bootout system /System/Library/LaunchDaemons/com.apple.metadata.mds.plist
|
||||
@@ -983,8 +988,8 @@ jobs:
|
||||
pushd /tmp/proto
|
||||
curl -L -o protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v23.4/protoc-23.4-linux-x86_64.zip
|
||||
unzip protoc.zip
|
||||
cp -r ./bin/* /usr/local/bin
|
||||
cp -r ./include /usr/local/bin/include
|
||||
sudo cp -r ./bin/* /usr/local/bin
|
||||
sudo cp -r ./include /usr/local/bin/include
|
||||
popd
|
||||
|
||||
- name: Setup Go
|
||||
@@ -1082,7 +1087,7 @@ jobs:
|
||||
- name: Switch XCode Version
|
||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0
|
||||
with:
|
||||
xcode-version: "16.0.0"
|
||||
xcode-version: "16.1.0"
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
@@ -1220,8 +1225,8 @@ jobs:
|
||||
id: gcloud_auth
|
||||
uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10
|
||||
with:
|
||||
workload_identity_provider: ${{ secrets.GCP_CODE_SIGNING_WORKLOAD_ID_PROVIDER }}
|
||||
service_account: ${{ secrets.GCP_CODE_SIGNING_SERVICE_ACCOUNT }}
|
||||
workload_identity_provider: ${{ vars.GCP_CODE_SIGNING_WORKLOAD_ID_PROVIDER }}
|
||||
service_account: ${{ vars.GCP_CODE_SIGNING_SERVICE_ACCOUNT }}
|
||||
token_format: "access_token"
|
||||
|
||||
- name: Setup GCloud SDK
|
||||
@@ -1259,6 +1264,8 @@ jobs:
|
||||
# do (see above).
|
||||
CODER_SIGN_WINDOWS: "1"
|
||||
CODER_WINDOWS_RESOURCES: "1"
|
||||
CODER_SIGN_GPG: "1"
|
||||
CODER_GPG_RELEASE_KEY_BASE64: ${{ secrets.GPG_RELEASE_KEY_BASE64 }}
|
||||
EV_KEY: ${{ secrets.EV_KEY }}
|
||||
EV_KEYSTORE: ${{ secrets.EV_KEYSTORE }}
|
||||
EV_TSA_URL: ${{ secrets.EV_TSA_URL }}
|
||||
@@ -1519,8 +1526,8 @@ jobs:
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10
|
||||
with:
|
||||
workload_identity_provider: projects/573722524737/locations/global/workloadIdentityPools/github/providers/github
|
||||
service_account: coder-ci@coder-dogfood.iam.gserviceaccount.com
|
||||
workload_identity_provider: ${{ vars.GCP_WORKLOAD_ID_PROVIDER }}
|
||||
service_account: ${{ vars.GCP_SERVICE_ACCOUNT }}
|
||||
|
||||
- name: Set up Google Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4
|
||||
|
||||
4
.github/workflows/dogfood.yaml
vendored
4
.github/workflows/dogfood.yaml
vendored
@@ -127,8 +127,8 @@ jobs:
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10
|
||||
with:
|
||||
workload_identity_provider: projects/573722524737/locations/global/workloadIdentityPools/github/providers/github
|
||||
service_account: coder-ci@coder-dogfood.iam.gserviceaccount.com
|
||||
workload_identity_provider: ${{ vars.GCP_WORKLOAD_ID_PROVIDER }}
|
||||
service_account: ${{ vars.GCP_SERVICE_ACCOUNT }}
|
||||
|
||||
- name: Terraform init and validate
|
||||
run: |
|
||||
|
||||
2
.github/workflows/pr-deploy.yaml
vendored
2
.github/workflows/pr-deploy.yaml
vendored
@@ -420,7 +420,7 @@ jobs:
|
||||
curl -fsSL "$URL" -o "${DEST}"
|
||||
chmod +x "${DEST}"
|
||||
"${DEST}" version
|
||||
mv "${DEST}" /usr/local/bin/coder
|
||||
sudo mv "${DEST}" /usr/local/bin/coder
|
||||
|
||||
- name: Create first user
|
||||
if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true'
|
||||
|
||||
36
.github/workflows/release.yaml
vendored
36
.github/workflows/release.yaml
vendored
@@ -60,7 +60,7 @@ jobs:
|
||||
- name: Switch XCode Version
|
||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0
|
||||
with:
|
||||
xcode-version: "16.0.0"
|
||||
xcode-version: "16.1.0"
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
@@ -288,8 +288,8 @@ jobs:
|
||||
id: gcloud_auth
|
||||
uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10
|
||||
with:
|
||||
workload_identity_provider: ${{ secrets.GCP_CODE_SIGNING_WORKLOAD_ID_PROVIDER }}
|
||||
service_account: ${{ secrets.GCP_CODE_SIGNING_SERVICE_ACCOUNT }}
|
||||
workload_identity_provider: ${{ vars.GCP_CODE_SIGNING_WORKLOAD_ID_PROVIDER }}
|
||||
service_account: ${{ vars.GCP_CODE_SIGNING_SERVICE_ACCOUNT }}
|
||||
token_format: "access_token"
|
||||
|
||||
- name: Setup GCloud SDK
|
||||
@@ -323,6 +323,8 @@ jobs:
|
||||
env:
|
||||
CODER_SIGN_WINDOWS: "1"
|
||||
CODER_SIGN_DARWIN: "1"
|
||||
CODER_SIGN_GPG: "1"
|
||||
CODER_GPG_RELEASE_KEY_BASE64: ${{ secrets.GPG_RELEASE_KEY_BASE64 }}
|
||||
CODER_WINDOWS_RESOURCES: "1"
|
||||
AC_CERTIFICATE_FILE: /tmp/apple_cert.p12
|
||||
AC_CERTIFICATE_PASSWORD_FILE: /tmp/apple_cert_password.txt
|
||||
@@ -632,6 +634,30 @@ jobs:
|
||||
- name: ls build
|
||||
run: ls -lh build
|
||||
|
||||
- name: Publish Coder CLI binaries and detached signatures to GCS
|
||||
if: ${{ !inputs.dry_run && github.ref == 'refs/heads/main' && github.repository_owner == 'coder'}}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
|
||||
version="$(./scripts/version.sh)"
|
||||
|
||||
# Source array of slim binaries
|
||||
declare -A binaries
|
||||
binaries["coder-darwin-amd64"]="coder-slim_${version}_darwin_amd64"
|
||||
binaries["coder-darwin-arm64"]="coder-slim_${version}_darwin_arm64"
|
||||
binaries["coder-linux-amd64"]="coder-slim_${version}_linux_amd64"
|
||||
binaries["coder-linux-arm64"]="coder-slim_${version}_linux_arm64"
|
||||
binaries["coder-linux-armv7"]="coder-slim_${version}_linux_armv7"
|
||||
binaries["coder-windows-amd64.exe"]="coder-slim_${version}_windows_amd64.exe"
|
||||
binaries["coder-windows-arm64.exe"]="coder-slim_${version}_windows_arm64.exe"
|
||||
|
||||
for cli_name in "${!binaries[@]}"; do
|
||||
slim_binary="${binaries[$cli_name]}"
|
||||
detached_signature="${slim_binary}.asc"
|
||||
gcloud storage cp "./build/${slim_binary}" "gs://releases.coder.com/coder-cli/${version}/${cli_name}"
|
||||
gcloud storage cp "./build/${detached_signature}" "gs://releases.coder.com/coder-cli/${version}/${cli_name}.asc"
|
||||
done
|
||||
|
||||
- name: Publish release
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -673,8 +699,8 @@ jobs:
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10
|
||||
with:
|
||||
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_ID_PROVIDER }}
|
||||
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
|
||||
workload_identity_provider: ${{ vars.GCP_WORKLOAD_ID_PROVIDER }}
|
||||
service_account: ${{ vars.GCP_SERVICE_ACCOUNT }}
|
||||
|
||||
- name: Setup GCloud SDK
|
||||
uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # 2.1.4
|
||||
|
||||
4
Makefile
4
Makefile
@@ -250,6 +250,10 @@ $(CODER_ALL_BINARIES): go.mod go.sum \
|
||||
fi
|
||||
|
||||
cp "$@" "./site/out/bin/coder-$$os-$$arch$$dot_ext"
|
||||
|
||||
if [[ "$${CODER_SIGN_GPG:-0}" == "1" ]]; then
|
||||
cp "$@.asc" "./site/out/bin/coder-$$os-$$arch$$dot_ext.asc"
|
||||
fi
|
||||
fi
|
||||
|
||||
# This task builds Coder Desktop dylibs
|
||||
|
||||
@@ -124,6 +124,7 @@ type Server struct {
|
||||
listeners map[net.Listener]struct{}
|
||||
conns map[net.Conn]struct{}
|
||||
sessions map[ssh.Session]struct{}
|
||||
processes map[*os.Process]struct{}
|
||||
closing chan struct{}
|
||||
// Wait for goroutines to exit, waited without
|
||||
// a lock on mu but protected by closing.
|
||||
@@ -182,6 +183,7 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
|
||||
fs: fs,
|
||||
conns: make(map[net.Conn]struct{}),
|
||||
sessions: make(map[ssh.Session]struct{}),
|
||||
processes: make(map[*os.Process]struct{}),
|
||||
logger: logger,
|
||||
|
||||
config: config,
|
||||
@@ -586,7 +588,10 @@ func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, mag
|
||||
// otherwise context cancellation will not propagate properly
|
||||
// and SSH server close may be delayed.
|
||||
cmd.SysProcAttr = cmdSysProcAttr()
|
||||
cmd.Cancel = cmdCancel(session.Context(), logger, cmd)
|
||||
|
||||
// to match OpenSSH, we don't actually tear a non-TTY command down, even if the session ends.
|
||||
// c.f. https://github.com/coder/coder/issues/18519#issuecomment-3019118271
|
||||
cmd.Cancel = nil
|
||||
|
||||
cmd.Stdout = session
|
||||
cmd.Stderr = session.Stderr()
|
||||
@@ -609,6 +614,16 @@ func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, mag
|
||||
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "no", "start_command").Add(1)
|
||||
return xerrors.Errorf("start: %w", err)
|
||||
}
|
||||
|
||||
// Since we don't cancel the process when the session stops, we still need to tear it down if we are closing. So
|
||||
// track it here.
|
||||
if !s.trackProcess(cmd.Process, true) {
|
||||
// must be closing
|
||||
err = cmdCancel(logger, cmd.Process)
|
||||
return xerrors.Errorf("failed to track process: %w", err)
|
||||
}
|
||||
defer s.trackProcess(cmd.Process, false)
|
||||
|
||||
sigs := make(chan ssh.Signal, 1)
|
||||
session.Signals(sigs)
|
||||
defer func() {
|
||||
@@ -1052,6 +1067,27 @@ func (s *Server) trackSession(ss ssh.Session, add bool) (ok bool) {
|
||||
return true
|
||||
}
|
||||
|
||||
// trackCommand registers the process with the server. If the server is
|
||||
// closing, the process is not registered and should be closed.
|
||||
//
|
||||
//nolint:revive
|
||||
func (s *Server) trackProcess(p *os.Process, add bool) (ok bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if add {
|
||||
if s.closing != nil {
|
||||
// Server closed.
|
||||
return false
|
||||
}
|
||||
s.wg.Add(1)
|
||||
s.processes[p] = struct{}{}
|
||||
return true
|
||||
}
|
||||
s.wg.Done()
|
||||
delete(s.processes, p)
|
||||
return true
|
||||
}
|
||||
|
||||
// Close the server and all active connections. Server can be re-used
|
||||
// after Close is done.
|
||||
func (s *Server) Close() error {
|
||||
@@ -1091,6 +1127,10 @@ func (s *Server) Close() error {
|
||||
_ = c.Close()
|
||||
}
|
||||
|
||||
for p := range s.processes {
|
||||
_ = cmdCancel(s.logger, p)
|
||||
}
|
||||
|
||||
s.logger.Debug(ctx, "closing SSH server")
|
||||
err := s.srv.Close()
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ package agentssh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"cdr.dev/slog"
|
||||
@@ -16,9 +16,7 @@ func cmdSysProcAttr() *syscall.SysProcAttr {
|
||||
}
|
||||
}
|
||||
|
||||
func cmdCancel(ctx context.Context, logger slog.Logger, cmd *exec.Cmd) func() error {
|
||||
return func() error {
|
||||
logger.Debug(ctx, "cmdCancel: sending SIGHUP to process and children", slog.F("pid", cmd.Process.Pid))
|
||||
return syscall.Kill(-cmd.Process.Pid, syscall.SIGHUP)
|
||||
}
|
||||
func cmdCancel(logger slog.Logger, p *os.Process) error {
|
||||
logger.Debug(context.Background(), "cmdCancel: sending SIGHUP to process and children", slog.F("pid", p.Pid))
|
||||
return syscall.Kill(-p.Pid, syscall.SIGHUP)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package agentssh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"cdr.dev/slog"
|
||||
@@ -12,14 +12,12 @@ func cmdSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{}
|
||||
}
|
||||
|
||||
func cmdCancel(ctx context.Context, logger slog.Logger, cmd *exec.Cmd) func() error {
|
||||
return func() error {
|
||||
logger.Debug(ctx, "cmdCancel: killing process", slog.F("pid", cmd.Process.Pid))
|
||||
// Windows doesn't support sending signals to process groups, so we
|
||||
// have to kill the process directly. In the future, we may want to
|
||||
// implement a more sophisticated solution for process groups on
|
||||
// Windows, but for now, this is a simple way to ensure that the
|
||||
// process is terminated when the context is cancelled.
|
||||
return cmd.Process.Kill()
|
||||
}
|
||||
func cmdCancel(logger slog.Logger, p *os.Process) error {
|
||||
logger.Debug(context.Background(), "cmdCancel: killing process", slog.F("pid", p.Pid))
|
||||
// Windows doesn't support sending signals to process groups, so we
|
||||
// have to kill the process directly. In the future, we may want to
|
||||
// implement a more sophisticated solution for process groups on
|
||||
// Windows, but for now, this is a simple way to ensure that the
|
||||
// process is terminated when the context is cancelled.
|
||||
return p.Kill()
|
||||
}
|
||||
|
||||
@@ -112,14 +112,19 @@ func (o sshConfigOptions) equal(other sshConfigOptions) bool {
|
||||
}
|
||||
|
||||
func (o sshConfigOptions) writeToBuffer(buf *bytes.Buffer) error {
|
||||
escapedCoderBinary, err := sshConfigExecEscape(o.coderBinaryPath, o.forceUnixSeparators)
|
||||
escapedCoderBinaryProxy, err := sshConfigProxyCommandEscape(o.coderBinaryPath, o.forceUnixSeparators)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("escape coder binary for ssh failed: %w", err)
|
||||
return xerrors.Errorf("escape coder binary for ProxyCommand failed: %w", err)
|
||||
}
|
||||
|
||||
escapedGlobalConfig, err := sshConfigExecEscape(o.globalConfigPath, o.forceUnixSeparators)
|
||||
escapedCoderBinaryMatchExec, err := sshConfigMatchExecEscape(o.coderBinaryPath)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("escape global config for ssh failed: %w", err)
|
||||
return xerrors.Errorf("escape coder binary for Match exec failed: %w", err)
|
||||
}
|
||||
|
||||
escapedGlobalConfig, err := sshConfigProxyCommandEscape(o.globalConfigPath, o.forceUnixSeparators)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("escape global config for ProxyCommand failed: %w", err)
|
||||
}
|
||||
|
||||
rootFlags := fmt.Sprintf("--global-config %s", escapedGlobalConfig)
|
||||
@@ -155,7 +160,7 @@ func (o sshConfigOptions) writeToBuffer(buf *bytes.Buffer) error {
|
||||
_, _ = buf.WriteString("\t")
|
||||
_, _ = fmt.Fprintf(buf,
|
||||
"ProxyCommand %s %s ssh --stdio%s --ssh-host-prefix %s %%h",
|
||||
escapedCoderBinary, rootFlags, flags, o.userHostPrefix,
|
||||
escapedCoderBinaryProxy, rootFlags, flags, o.userHostPrefix,
|
||||
)
|
||||
_, _ = buf.WriteString("\n")
|
||||
}
|
||||
@@ -174,11 +179,11 @@ func (o sshConfigOptions) writeToBuffer(buf *bytes.Buffer) error {
|
||||
// the ^^ options should always apply, but we only want to use the proxy command if Coder Connect is not running.
|
||||
if !o.skipProxyCommand {
|
||||
_, _ = fmt.Fprintf(buf, "\nMatch host *.%s !exec \"%s connect exists %%h\"\n",
|
||||
o.hostnameSuffix, escapedCoderBinary)
|
||||
o.hostnameSuffix, escapedCoderBinaryMatchExec)
|
||||
_, _ = buf.WriteString("\t")
|
||||
_, _ = fmt.Fprintf(buf,
|
||||
"ProxyCommand %s %s ssh --stdio%s --hostname-suffix %s %%h",
|
||||
escapedCoderBinary, rootFlags, flags, o.hostnameSuffix,
|
||||
escapedCoderBinaryProxy, rootFlags, flags, o.hostnameSuffix,
|
||||
)
|
||||
_, _ = buf.WriteString("\n")
|
||||
}
|
||||
@@ -759,7 +764,8 @@ func sshConfigSplitOnCoderSection(data []byte) (before, section []byte, after []
|
||||
return data, nil, nil, nil
|
||||
}
|
||||
|
||||
// sshConfigExecEscape quotes the string if it contains spaces, as per
|
||||
// sshConfigProxyCommandEscape prepares the path for use in ProxyCommand.
|
||||
// It quotes the string if it contains spaces, as per
|
||||
// `man 5 ssh_config`. However, OpenSSH uses exec in the users shell to
|
||||
// run the command, and as such the formatting/escape requirements
|
||||
// cannot simply be covered by `fmt.Sprintf("%q", path)`.
|
||||
@@ -804,7 +810,7 @@ func sshConfigSplitOnCoderSection(data []byte) (before, section []byte, after []
|
||||
// This is a control flag, and that is ok. It is a control flag
|
||||
// based on the OS of the user. Making this a different file is excessive.
|
||||
// nolint:revive
|
||||
func sshConfigExecEscape(path string, forceUnixPath bool) (string, error) {
|
||||
func sshConfigProxyCommandEscape(path string, forceUnixPath bool) (string, error) {
|
||||
if forceUnixPath {
|
||||
// This is a workaround for #7639, where the filepath separator is
|
||||
// incorrectly the Windows separator (\) instead of the unix separator (/).
|
||||
@@ -814,9 +820,9 @@ func sshConfigExecEscape(path string, forceUnixPath bool) (string, error) {
|
||||
// This is unlikely to ever happen, but newlines are allowed on
|
||||
// certain filesystems, but cannot be used inside ssh config.
|
||||
if strings.ContainsAny(path, "\n") {
|
||||
return "", xerrors.Errorf("invalid path: %s", path)
|
||||
return "", xerrors.Errorf("invalid path: %q", path)
|
||||
}
|
||||
// In the unlikely even that a path contains quotes, they must be
|
||||
// In the unlikely event that a path contains quotes, they must be
|
||||
// escaped so that they are not interpreted as shell quotes.
|
||||
if strings.Contains(path, "\"") {
|
||||
path = strings.ReplaceAll(path, "\"", "\\\"")
|
||||
|
||||
@@ -139,7 +139,7 @@ func Test_sshConfigSplitOnCoderSection(t *testing.T) {
|
||||
// This test tries to mimic the behavior of OpenSSH
|
||||
// when executing e.g. a ProxyCommand.
|
||||
// nolint:tparallel
|
||||
func Test_sshConfigExecEscape(t *testing.T) {
|
||||
func Test_sshConfigProxyCommandEscape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
@@ -171,7 +171,7 @@ func Test_sshConfigExecEscape(t *testing.T) {
|
||||
err = os.WriteFile(bin, contents, 0o755) //nolint:gosec
|
||||
require.NoError(t, err)
|
||||
|
||||
escaped, err := sshConfigExecEscape(bin, false)
|
||||
escaped, err := sshConfigProxyCommandEscape(bin, false)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
@@ -186,6 +186,63 @@ func Test_sshConfigExecEscape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// This test tries to mimic the behavior of OpenSSH
|
||||
// when executing e.g. a match exec command.
|
||||
// nolint:tparallel
|
||||
func Test_sshConfigMatchExecEscape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantErrOther bool
|
||||
wantErrWindows bool
|
||||
}{
|
||||
{"no spaces", "simple", false, false},
|
||||
{"spaces", "path with spaces", false, false},
|
||||
{"quotes", "path with \"quotes\"", true, true},
|
||||
{"backslashes", "path with\\backslashes", false, false},
|
||||
{"tabs", "path with \ttabs", false, true},
|
||||
{"newline fails", "path with \nnewline", true, true},
|
||||
}
|
||||
// nolint:paralleltest // Fixes a flake
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := "/bin/sh"
|
||||
arg := "-c"
|
||||
contents := []byte("#!/bin/sh\necho yay\n")
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = "cmd.exe"
|
||||
arg = "/c"
|
||||
contents = []byte("@echo yay\n")
|
||||
}
|
||||
|
||||
dir := filepath.Join(t.TempDir(), tt.path)
|
||||
bin := filepath.Join(dir, "coder.bat") // Windows will treat it as batch, Linux doesn't care
|
||||
escaped, err := sshConfigMatchExecEscape(bin)
|
||||
if (runtime.GOOS == "windows" && tt.wantErrWindows) || (runtime.GOOS != "windows" && tt.wantErrOther) {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.MkdirAll(dir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(bin, contents, 0o755) //nolint:gosec
|
||||
require.NoError(t, err)
|
||||
|
||||
// OpenSSH processes %% escape sequences into %
|
||||
escaped = strings.ReplaceAll(escaped, "%%", "%")
|
||||
b, err := exec.Command(cmd, arg, escaped).CombinedOutput() //nolint:gosec
|
||||
require.NoError(t, err)
|
||||
got := strings.TrimSpace(string(b))
|
||||
require.Equal(t, "yay", got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_sshConfigExecEscapeSeparatorForce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -236,7 +293,7 @@ func Test_sshConfigExecEscapeSeparatorForce(t *testing.T) {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
found, err := sshConfigExecEscape(tt.path, tt.forceUnix)
|
||||
found, err := sshConfigProxyCommandEscape(tt.path, tt.forceUnix)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
|
||||
@@ -2,4 +2,35 @@
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
var hideForceUnixSlashes = true
|
||||
|
||||
// sshConfigMatchExecEscape prepares the path for use in `Match exec` statement.
|
||||
//
|
||||
// OpenSSH parses the Match line with a very simple tokenizer that accepts "-enclosed strings for the exec command, and
|
||||
// has no supported escape sequences for ". This means we cannot include " within the command to execute.
|
||||
func sshConfigMatchExecEscape(path string) (string, error) {
|
||||
// This is unlikely to ever happen, but newlines are allowed on
|
||||
// certain filesystems, but cannot be used inside ssh config.
|
||||
if strings.ContainsAny(path, "\n") {
|
||||
return "", xerrors.Errorf("invalid path: %s", path)
|
||||
}
|
||||
// Quotes are allowed in path names on unix-like file systems, but OpenSSH's parsing of `Match exec` doesn't allow
|
||||
// them.
|
||||
if strings.Contains(path, `"`) {
|
||||
return "", xerrors.Errorf("path must not contain quotes: %q", path)
|
||||
}
|
||||
|
||||
// OpenSSH passes the match exec string directly to the user's shell. sh, bash and zsh accept spaces, tabs and
|
||||
// backslashes simply escaped by a `\`. It's hard to predict exactly what more exotic shells might do, but this
|
||||
// should work for macOS and most Linux distros in their default configuration.
|
||||
path = strings.ReplaceAll(path, `\`, `\\`) // must be first, since later replacements add backslashes.
|
||||
path = strings.ReplaceAll(path, " ", "\\ ")
|
||||
path = strings.ReplaceAll(path, "\t", "\\\t")
|
||||
return path, nil
|
||||
}
|
||||
|
||||
@@ -2,5 +2,58 @@
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// Must be a var for unit tests to conform behavior
|
||||
var hideForceUnixSlashes = false
|
||||
|
||||
// sshConfigMatchExecEscape prepares the path for use in `Match exec` statement.
|
||||
//
|
||||
// OpenSSH parses the Match line with a very simple tokenizer that accepts "-enclosed strings for the exec command, and
|
||||
// has no supported escape sequences for ". This means we cannot include " within the command to execute.
|
||||
//
|
||||
// To make matters worse, on Windows, OpenSSH passes the string directly to cmd.exe for execution, and as far as I can
|
||||
// tell, the only supported way to call a path that has spaces in it is to surround it with ".
|
||||
//
|
||||
// So, we can't actually include " directly, but here is a horrible workaround:
|
||||
//
|
||||
// "for /f %%a in ('powershell.exe -Command [char]34') do @cmd.exe /c %%aC:\Program Files\Coder\bin\coder.exe%%a connect exists %h"
|
||||
//
|
||||
// The key insight here is to store the character " in a variable (%a in this case, but the % itself needs to be
|
||||
// escaped, so it becomes %%a), and then use that variable to construct the double-quoted path:
|
||||
//
|
||||
// %%aC:\Program Files\Coder\bin\coder.exe%%a.
|
||||
//
|
||||
// How do we generate a single " character without actually using that character? I couldn't find any command in cmd.exe
|
||||
// to do it, but powershell.exe can convert ASCII to characters like this: `[char]34` (where 34 is the code point for ").
|
||||
//
|
||||
// Other notes:
|
||||
// - @ in `@cmd.exe` suppresses echoing it, so you don't get this command printed
|
||||
// - we need another invocation of cmd.exe (e.g. `do @cmd.exe /c %%aC:\Program Files\Coder\bin\coder.exe%%a`). Without
|
||||
// it the double-quote gets interpreted as part of the path, and you get: '"C:\Program' is not recognized.
|
||||
// Constructing the string and then passing it to another instance of cmd.exe does this trick here.
|
||||
// - OpenSSH passes the `Match exec` command to cmd.exe regardless of whether the user has a unix-like shell like
|
||||
// git bash, so we don't have a `forceUnixPath` option like for the ProxyCommand which does respect the user's
|
||||
// configured shell on Windows.
|
||||
func sshConfigMatchExecEscape(path string) (string, error) {
|
||||
// This is unlikely to ever happen, but newlines are allowed on
|
||||
// certain filesystems, but cannot be used inside ssh config.
|
||||
if strings.ContainsAny(path, "\n") {
|
||||
return "", xerrors.Errorf("invalid path: %s", path)
|
||||
}
|
||||
// Windows does not allow double-quotes or tabs in paths. If we get one it is an error.
|
||||
if strings.ContainsAny(path, "\"\t") {
|
||||
return "", xerrors.Errorf("path must not contain quotes or tabs: %q", path)
|
||||
}
|
||||
|
||||
if strings.ContainsAny(path, " ") {
|
||||
// c.f. function comment for how this works.
|
||||
path = fmt.Sprintf("for /f %%%%a in ('powershell.exe -Command [char]34') do @cmd.exe /c %%%%a%s%%%%a", path) //nolint:gocritic // We don't want %q here.
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
11
cli/root.go
11
cli/root.go
@@ -1060,11 +1060,12 @@ func cliHumanFormatError(from string, err error, opts *formatOpts) (string, bool
|
||||
return formatRunCommandError(cmdErr, opts), true
|
||||
}
|
||||
|
||||
uw, ok := err.(interface{ Unwrap() error })
|
||||
if ok {
|
||||
msg, special := cliHumanFormatError(from+traceError(err), uw.Unwrap(), opts)
|
||||
if special {
|
||||
return msg, special
|
||||
if uw, ok := err.(interface{ Unwrap() error }); ok {
|
||||
if unwrapped := uw.Unwrap(); unwrapped != nil {
|
||||
msg, special := cliHumanFormatError(from+traceError(err), unwrapped, opts)
|
||||
if special {
|
||||
return msg, special
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we got here, that means that the wrapped error chain does not have
|
||||
|
||||
@@ -1594,12 +1594,14 @@ func writeCoderConnectNetInfo(ctx context.Context, networkInfoDir string) error
|
||||
// Converts workspace name input to owner/workspace.agent format
|
||||
// Possible valid input formats:
|
||||
// workspace
|
||||
// workspace.agent
|
||||
// owner/workspace
|
||||
// owner--workspace
|
||||
// owner/workspace--agent
|
||||
// owner/workspace.agent
|
||||
// owner--workspace--agent
|
||||
// owner--workspace.agent
|
||||
// agent.workspace.owner - for parity with Coder Connect
|
||||
func normalizeWorkspaceInput(input string) string {
|
||||
// Split on "/", "--", and "."
|
||||
parts := workspaceNameRe.Split(input, -1)
|
||||
@@ -1608,8 +1610,15 @@ func normalizeWorkspaceInput(input string) string {
|
||||
case 1:
|
||||
return input // "workspace"
|
||||
case 2:
|
||||
if strings.Contains(input, ".") {
|
||||
return fmt.Sprintf("%s.%s", parts[0], parts[1]) // "workspace.agent"
|
||||
}
|
||||
return fmt.Sprintf("%s/%s", parts[0], parts[1]) // "owner/workspace"
|
||||
case 3:
|
||||
// If the only separator is a dot, it's the Coder Connect format
|
||||
if !strings.Contains(input, "/") && !strings.Contains(input, "--") {
|
||||
return fmt.Sprintf("%s/%s.%s", parts[2], parts[1], parts[0]) // "owner/workspace.agent"
|
||||
}
|
||||
return fmt.Sprintf("%s/%s.%s", parts[0], parts[1], parts[2]) // "owner/workspace.agent"
|
||||
default:
|
||||
return input // Fallback
|
||||
|
||||
@@ -107,12 +107,14 @@ func TestSSH(t *testing.T) {
|
||||
|
||||
cases := []string{
|
||||
"myworkspace",
|
||||
"myworkspace.dev",
|
||||
"myuser/myworkspace",
|
||||
"myuser--myworkspace",
|
||||
"myuser/myworkspace--dev",
|
||||
"myuser/myworkspace.dev",
|
||||
"myuser--myworkspace--dev",
|
||||
"myuser--myworkspace.dev",
|
||||
"dev.myworkspace.myuser",
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
|
||||
2
cli/testdata/coder_list_--output_json.golden
vendored
2
cli/testdata/coder_list_--output_json.golden
vendored
@@ -23,7 +23,7 @@
|
||||
"workspace_id": "===========[workspace ID]===========",
|
||||
"workspace_name": "test-workspace",
|
||||
"workspace_owner_id": "==========[first user ID]===========",
|
||||
"workspace_owner_username": "testuser",
|
||||
"workspace_owner_name": "testuser",
|
||||
"template_version_id": "============[version ID]============",
|
||||
"template_version_name": "===========[version name]===========",
|
||||
"build_number": 1,
|
||||
|
||||
5
coderd/apidoc/docs.go
generated
5
coderd/apidoc/docs.go
generated
@@ -17002,6 +17002,7 @@ const docTemplate = `{
|
||||
"format": "uuid"
|
||||
},
|
||||
"owner_name": {
|
||||
"description": "OwnerName is the username of the owner of the workspace.",
|
||||
"type": "string"
|
||||
},
|
||||
"template_active_version_id": {
|
||||
@@ -17847,9 +17848,7 @@ const docTemplate = `{
|
||||
"format": "uuid"
|
||||
},
|
||||
"workspace_owner_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_owner_username": {
|
||||
"description": "WorkspaceOwnerName is the username of the owner of the workspace.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
|
||||
5
coderd/apidoc/swagger.json
generated
5
coderd/apidoc/swagger.json
generated
@@ -15507,6 +15507,7 @@
|
||||
"format": "uuid"
|
||||
},
|
||||
"owner_name": {
|
||||
"description": "OwnerName is the username of the owner of the workspace.",
|
||||
"type": "string"
|
||||
},
|
||||
"template_active_version_id": {
|
||||
@@ -16297,9 +16298,7 @@
|
||||
"format": "uuid"
|
||||
},
|
||||
"workspace_owner_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_owner_username": {
|
||||
"description": "WorkspaceOwnerName is the username of the owner of the workspace.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,7 +462,7 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit
|
||||
if getWorkspaceErr != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("/@%s/%s", workspace.OwnerUsername, workspace.Name)
|
||||
return fmt.Sprintf("/@%s/%s", workspace.OwnerName, workspace.Name)
|
||||
|
||||
case database.ResourceTypeWorkspaceApp:
|
||||
if additionalFields.WorkspaceOwner != "" && additionalFields.WorkspaceName != "" {
|
||||
@@ -472,7 +472,7 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit
|
||||
if getWorkspaceErr != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("/@%s/%s", workspace.OwnerUsername, workspace.Name)
|
||||
return fmt.Sprintf("/@%s/%s", workspace.OwnerName, workspace.Name)
|
||||
|
||||
case database.ResourceTypeOauth2ProviderApp:
|
||||
return fmt.Sprintf("/deployment/oauth2-provider/apps/%s", alog.AuditLog.ResourceID)
|
||||
|
||||
@@ -860,7 +860,7 @@ func New(options *Options) *API {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
},
|
||||
// httpmw.CSRF(options.DeploymentValues.HTTPCookies),
|
||||
httpmw.CSRF(options.DeploymentValues.HTTPCookies),
|
||||
)
|
||||
|
||||
// This incurs a performance hit from the middleware, but is required to make sure
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/wsjson"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
@@ -211,6 +212,86 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) {
|
||||
require.Zero(t, setup.api.FileCache.Count())
|
||||
})
|
||||
|
||||
t.Run("RebuildParameters", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/modules/main.tf")
|
||||
require.NoError(t, err)
|
||||
|
||||
modulesArchive, err := terraform.GetModulesArchive(os.DirFS("testdata/parameters/modules"))
|
||||
require.NoError(t, err)
|
||||
|
||||
setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{
|
||||
provisionerDaemonVersion: provProto.CurrentVersion.String(),
|
||||
mainTF: dynamicParametersTerraformSource,
|
||||
modulesArchive: modulesArchive,
|
||||
plan: nil,
|
||||
static: nil,
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
stream := setup.stream
|
||||
previews := stream.Chan()
|
||||
|
||||
// Should see the output of the module represented
|
||||
preview := testutil.RequireReceive(ctx, t, previews)
|
||||
require.Equal(t, -1, preview.ID)
|
||||
require.Empty(t, preview.Diagnostics)
|
||||
|
||||
require.Len(t, preview.Parameters, 1)
|
||||
require.Equal(t, "jetbrains_ide", preview.Parameters[0].Name)
|
||||
require.True(t, preview.Parameters[0].Value.Valid)
|
||||
require.Equal(t, "CL", preview.Parameters[0].Value.Value)
|
||||
_ = stream.Close(websocket.StatusGoingAway)
|
||||
|
||||
wrk := coderdtest.CreateWorkspace(t, setup.client, setup.template.ID, func(request *codersdk.CreateWorkspaceRequest) {
|
||||
request.RichParameterValues = []codersdk.WorkspaceBuildParameter{
|
||||
{
|
||||
Name: preview.Parameters[0].Name,
|
||||
Value: "GO",
|
||||
},
|
||||
}
|
||||
})
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID)
|
||||
|
||||
params, err := setup.client.WorkspaceBuildParameters(ctx, wrk.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, params, 1)
|
||||
require.Equal(t, "jetbrains_ide", params[0].Name)
|
||||
require.Equal(t, "GO", params[0].Value)
|
||||
|
||||
// A helper function to assert params
|
||||
doTransition := func(t *testing.T, trans codersdk.WorkspaceTransition) {
|
||||
t.Helper()
|
||||
|
||||
fooVal := coderdtest.RandomUsername(t)
|
||||
bld, err := setup.client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
TemplateVersionID: setup.template.ActiveVersionID,
|
||||
Transition: trans,
|
||||
RichParameterValues: []codersdk.WorkspaceBuildParameter{
|
||||
// No validation, so this should work as is.
|
||||
// Overwrite the value on each transition
|
||||
{Name: "foo", Value: fooVal},
|
||||
},
|
||||
EnableDynamicParameters: ptr.Ref(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID)
|
||||
|
||||
latestParams, err := setup.client.WorkspaceBuildParameters(ctx, bld.ID)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, latestParams, []codersdk.WorkspaceBuildParameter{
|
||||
{Name: "jetbrains_ide", Value: "GO"},
|
||||
{Name: "foo", Value: fooVal},
|
||||
})
|
||||
}
|
||||
|
||||
// Restart the workspace, then delete. Asserting params on all builds.
|
||||
doTransition(t, codersdk.WorkspaceTransitionStop)
|
||||
doTransition(t, codersdk.WorkspaceTransitionStart)
|
||||
doTransition(t, codersdk.WorkspaceTransitionDelete)
|
||||
})
|
||||
|
||||
t.Run("BadOwner", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -266,9 +347,10 @@ type setupDynamicParamsTestParams struct {
|
||||
}
|
||||
|
||||
type dynamicParamsTest struct {
|
||||
client *codersdk.Client
|
||||
api *coderd.API
|
||||
stream *wsjson.Stream[codersdk.DynamicParametersResponse, codersdk.DynamicParametersRequest]
|
||||
client *codersdk.Client
|
||||
api *coderd.API
|
||||
stream *wsjson.Stream[codersdk.DynamicParametersResponse, codersdk.DynamicParametersRequest]
|
||||
template codersdk.Template
|
||||
}
|
||||
|
||||
func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dynamicParamsTest {
|
||||
@@ -300,7 +382,7 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, templateAdmin, owner.OrganizationID, files)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, version.ID)
|
||||
_ = coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID)
|
||||
tpl := coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
stream, err := templateAdmin.TemplateVersionDynamicParameters(ctx, version.ID)
|
||||
@@ -321,9 +403,10 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn
|
||||
})
|
||||
|
||||
return dynamicParamsTest{
|
||||
client: ownerClient,
|
||||
stream: stream,
|
||||
api: api,
|
||||
client: ownerClient,
|
||||
api: api,
|
||||
stream: stream,
|
||||
template: tpl,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1095,8 +1095,7 @@ func (api *API) convertWorkspaceBuild(
|
||||
CreatedAt: build.CreatedAt,
|
||||
UpdatedAt: build.UpdatedAt,
|
||||
WorkspaceOwnerID: workspace.OwnerID,
|
||||
WorkspaceOwnerName: workspace.OwnerName,
|
||||
WorkspaceOwnerUsername: workspace.OwnerUsername,
|
||||
WorkspaceOwnerName: workspace.OwnerUsername,
|
||||
WorkspaceOwnerAvatarURL: workspace.OwnerAvatarUrl,
|
||||
WorkspaceID: build.WorkspaceID,
|
||||
WorkspaceName: workspace.Name,
|
||||
|
||||
@@ -78,8 +78,7 @@ func TestWorkspaceBuild(t *testing.T) {
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
wb, err := client.WorkspaceBuild(testutil.Context(t, testutil.WaitShort), workspace.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, up.Username, wb.WorkspaceOwnerUsername)
|
||||
require.Equal(t, up.Name, wb.WorkspaceOwnerName)
|
||||
require.Equal(t, up.Username, wb.WorkspaceOwnerName)
|
||||
require.Equal(t, up.AvatarURL, wb.WorkspaceOwnerAvatarURL)
|
||||
}
|
||||
|
||||
|
||||
@@ -623,6 +623,11 @@ func (b *Builder) getParameters() (names, values []string, err error) {
|
||||
return nil, nil, BuildError{http.StatusBadRequest, "Unable to build workspace with unsupported parameters", err}
|
||||
}
|
||||
|
||||
lastBuildParameterValues := db2sdk.WorkspaceBuildParameters(lastBuildParameters)
|
||||
resolver := codersdk.ParameterResolver{
|
||||
Rich: lastBuildParameterValues,
|
||||
}
|
||||
|
||||
// Dynamic parameters skip all parameter validation.
|
||||
// Deleting a workspace also should skip parameter validation.
|
||||
// Pass the user's input as is.
|
||||
@@ -632,19 +637,34 @@ func (b *Builder) getParameters() (names, values []string, err error) {
|
||||
// conditional parameter existence, the static frame of reference
|
||||
// is not sufficient. So assume the user is correct, or pull in the
|
||||
// dynamic param code to find the actual parameters.
|
||||
latestValues := make(map[string]string, len(b.richParameterValues))
|
||||
for _, latest := range b.richParameterValues {
|
||||
latestValues[latest.Name] = latest.Value
|
||||
}
|
||||
|
||||
// Merge the inputs with values from the previous build.
|
||||
for _, last := range lastBuildParameterValues {
|
||||
// TODO: Ideally we use the resolver here and look at parameter
|
||||
// fields such as 'ephemeral'. This requires loading the terraform
|
||||
// files. For now, just send the previous inputs as is.
|
||||
if _, exists := latestValues[last.Name]; exists {
|
||||
// latestValues take priority, so skip this previous value.
|
||||
continue
|
||||
}
|
||||
names = append(names, last.Name)
|
||||
values = append(values, last.Value)
|
||||
}
|
||||
|
||||
for _, value := range b.richParameterValues {
|
||||
names = append(names, value.Name)
|
||||
values = append(values, value.Value)
|
||||
}
|
||||
|
||||
b.parameterNames = &names
|
||||
b.parameterValues = &values
|
||||
return names, values, nil
|
||||
}
|
||||
|
||||
resolver := codersdk.ParameterResolver{
|
||||
Rich: db2sdk.WorkspaceBuildParameters(lastBuildParameters),
|
||||
}
|
||||
|
||||
for _, templateVersionParameter := range templateVersionParameters {
|
||||
tvp, err := db2sdk.TemplateVersionParameter(templateVersionParameter)
|
||||
if err != nil {
|
||||
|
||||
@@ -51,14 +51,14 @@ const (
|
||||
// WorkspaceBuild is an at-point representation of a workspace state.
|
||||
// BuildNumbers start at 1 and increase by 1 for each subsequent build
|
||||
type WorkspaceBuild struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
|
||||
WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"`
|
||||
WorkspaceName string `json:"workspace_name"`
|
||||
WorkspaceOwnerID uuid.UUID `json:"workspace_owner_id" format:"uuid"`
|
||||
WorkspaceOwnerName string `json:"workspace_owner_name,omitempty"`
|
||||
WorkspaceOwnerUsername string `json:"workspace_owner_username"`
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
|
||||
WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"`
|
||||
WorkspaceName string `json:"workspace_name"`
|
||||
WorkspaceOwnerID uuid.UUID `json:"workspace_owner_id" format:"uuid"`
|
||||
// WorkspaceOwnerName is the username of the owner of the workspace.
|
||||
WorkspaceOwnerName string `json:"workspace_owner_name"`
|
||||
WorkspaceOwnerAvatarURL string `json:"workspace_owner_avatar_url,omitempty"`
|
||||
TemplateVersionID uuid.UUID `json:"template_version_id" format:"uuid"`
|
||||
TemplateVersionName string `json:"template_version_name"`
|
||||
|
||||
@@ -26,10 +26,11 @@ const (
|
||||
// Workspace is a deployment of a template. It references a specific
|
||||
// version and can be updated.
|
||||
type Workspace struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
|
||||
OwnerID uuid.UUID `json:"owner_id" format:"uuid"`
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
|
||||
OwnerID uuid.UUID `json:"owner_id" format:"uuid"`
|
||||
// OwnerName is the username of the owner of the workspace.
|
||||
OwnerName string `json:"owner_name"`
|
||||
OwnerAvatarURL string `json:"owner_avatar_url"`
|
||||
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
|
||||
@@ -49,7 +50,6 @@ type Workspace struct {
|
||||
AutostartSchedule *string `json:"autostart_schedule,omitempty"`
|
||||
TTLMillis *int64 `json:"ttl_ms,omitempty"`
|
||||
LastUsedAt time.Time `json:"last_used_at" format:"date-time"`
|
||||
|
||||
// DeletingAt indicates the time at which the workspace will be permanently deleted.
|
||||
// A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value)
|
||||
// and a value has been specified for time_til_dormant_autodelete on its template.
|
||||
|
||||
18
docs/reference/api/builds.md
generated
18
docs/reference/api/builds.md
generated
@@ -225,8 +225,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam
|
||||
"workspace_name": "string",
|
||||
"workspace_owner_avatar_url": "string",
|
||||
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
|
||||
"workspace_owner_name": "string",
|
||||
"workspace_owner_username": "string"
|
||||
"workspace_owner_name": "string"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -461,8 +460,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \
|
||||
"workspace_name": "string",
|
||||
"workspace_owner_avatar_url": "string",
|
||||
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
|
||||
"workspace_owner_name": "string",
|
||||
"workspace_owner_username": "string"
|
||||
"workspace_owner_name": "string"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1176,8 +1174,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta
|
||||
"workspace_name": "string",
|
||||
"workspace_owner_avatar_url": "string",
|
||||
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
|
||||
"workspace_owner_name": "string",
|
||||
"workspace_owner_username": "string"
|
||||
"workspace_owner_name": "string"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1485,8 +1482,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \
|
||||
"workspace_name": "string",
|
||||
"workspace_owner_avatar_url": "string",
|
||||
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
|
||||
"workspace_owner_name": "string",
|
||||
"workspace_owner_username": "string"
|
||||
"workspace_owner_name": "string"
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -1658,8 +1654,7 @@ Status Code **200**
|
||||
| `» workspace_name` | string | false | | |
|
||||
| `» workspace_owner_avatar_url` | string | false | | |
|
||||
| `» workspace_owner_id` | string(uuid) | false | | |
|
||||
| `» workspace_owner_name` | string | false | | |
|
||||
| `» workspace_owner_username` | string | false | | |
|
||||
| `» workspace_owner_name` | string | false | | Workspace owner name is the username of the owner of the workspace. |
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
@@ -1972,8 +1967,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \
|
||||
"workspace_name": "string",
|
||||
"workspace_owner_avatar_url": "string",
|
||||
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
|
||||
"workspace_owner_name": "string",
|
||||
"workspace_owner_username": "string"
|
||||
"workspace_owner_name": "string"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
62
docs/reference/api/schemas.md
generated
62
docs/reference/api/schemas.md
generated
@@ -8409,8 +8409,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
"workspace_name": "string",
|
||||
"workspace_owner_avatar_url": "string",
|
||||
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
|
||||
"workspace_owner_name": "string",
|
||||
"workspace_owner_username": "string"
|
||||
"workspace_owner_name": "string"
|
||||
},
|
||||
"name": "string",
|
||||
"next_start_at": "2019-08-24T14:15:22Z",
|
||||
@@ -8456,7 +8455,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
| `outdated` | boolean | false | | |
|
||||
| `owner_avatar_url` | string | false | | |
|
||||
| `owner_id` | string | false | | |
|
||||
| `owner_name` | string | false | | |
|
||||
| `owner_name` | string | false | | Owner name is the username of the owner of the workspace. |
|
||||
| `template_active_version_id` | string | false | | |
|
||||
| `template_allow_user_cancel_workspace_jobs` | boolean | false | | |
|
||||
| `template_display_name` | string | false | | |
|
||||
@@ -9401,39 +9400,37 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
"workspace_name": "string",
|
||||
"workspace_owner_avatar_url": "string",
|
||||
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
|
||||
"workspace_owner_name": "string",
|
||||
"workspace_owner_username": "string"
|
||||
"workspace_owner_name": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|------------------------------|-------------------------------------------------------------------|----------|--------------|-------------|
|
||||
| `build_number` | integer | false | | |
|
||||
| `created_at` | string | false | | |
|
||||
| `daily_cost` | integer | false | | |
|
||||
| `deadline` | string | false | | |
|
||||
| `id` | string | false | | |
|
||||
| `initiator_id` | string | false | | |
|
||||
| `initiator_name` | string | false | | |
|
||||
| `job` | [codersdk.ProvisionerJob](#codersdkprovisionerjob) | false | | |
|
||||
| `matched_provisioners` | [codersdk.MatchedProvisioners](#codersdkmatchedprovisioners) | false | | |
|
||||
| `max_deadline` | string | false | | |
|
||||
| `reason` | [codersdk.BuildReason](#codersdkbuildreason) | false | | |
|
||||
| `resources` | array of [codersdk.WorkspaceResource](#codersdkworkspaceresource) | false | | |
|
||||
| `status` | [codersdk.WorkspaceStatus](#codersdkworkspacestatus) | false | | |
|
||||
| `template_version_id` | string | false | | |
|
||||
| `template_version_name` | string | false | | |
|
||||
| `template_version_preset_id` | string | false | | |
|
||||
| `transition` | [codersdk.WorkspaceTransition](#codersdkworkspacetransition) | false | | |
|
||||
| `updated_at` | string | false | | |
|
||||
| `workspace_id` | string | false | | |
|
||||
| `workspace_name` | string | false | | |
|
||||
| `workspace_owner_avatar_url` | string | false | | |
|
||||
| `workspace_owner_id` | string | false | | |
|
||||
| `workspace_owner_name` | string | false | | |
|
||||
| `workspace_owner_username` | string | false | | |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|------------------------------|-------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------|
|
||||
| `build_number` | integer | false | | |
|
||||
| `created_at` | string | false | | |
|
||||
| `daily_cost` | integer | false | | |
|
||||
| `deadline` | string | false | | |
|
||||
| `id` | string | false | | |
|
||||
| `initiator_id` | string | false | | |
|
||||
| `initiator_name` | string | false | | |
|
||||
| `job` | [codersdk.ProvisionerJob](#codersdkprovisionerjob) | false | | |
|
||||
| `matched_provisioners` | [codersdk.MatchedProvisioners](#codersdkmatchedprovisioners) | false | | |
|
||||
| `max_deadline` | string | false | | |
|
||||
| `reason` | [codersdk.BuildReason](#codersdkbuildreason) | false | | |
|
||||
| `resources` | array of [codersdk.WorkspaceResource](#codersdkworkspaceresource) | false | | |
|
||||
| `status` | [codersdk.WorkspaceStatus](#codersdkworkspacestatus) | false | | |
|
||||
| `template_version_id` | string | false | | |
|
||||
| `template_version_name` | string | false | | |
|
||||
| `template_version_preset_id` | string | false | | |
|
||||
| `transition` | [codersdk.WorkspaceTransition](#codersdkworkspacetransition) | false | | |
|
||||
| `updated_at` | string | false | | |
|
||||
| `workspace_id` | string | false | | |
|
||||
| `workspace_name` | string | false | | |
|
||||
| `workspace_owner_avatar_url` | string | false | | |
|
||||
| `workspace_owner_id` | string | false | | |
|
||||
| `workspace_owner_name` | string | false | | Workspace owner name is the username of the owner of the workspace. |
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
@@ -10112,8 +10109,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
"workspace_name": "string",
|
||||
"workspace_owner_avatar_url": "string",
|
||||
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
|
||||
"workspace_owner_name": "string",
|
||||
"workspace_owner_username": "string"
|
||||
"workspace_owner_name": "string"
|
||||
},
|
||||
"name": "string",
|
||||
"next_start_at": "2019-08-24T14:15:22Z",
|
||||
|
||||
18
docs/reference/api/workspaces.md
generated
18
docs/reference/api/workspaces.md
generated
@@ -280,8 +280,7 @@ of the template will be used.
|
||||
"workspace_name": "string",
|
||||
"workspace_owner_avatar_url": "string",
|
||||
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
|
||||
"workspace_owner_name": "string",
|
||||
"workspace_owner_username": "string"
|
||||
"workspace_owner_name": "string"
|
||||
},
|
||||
"name": "string",
|
||||
"next_start_at": "2019-08-24T14:15:22Z",
|
||||
@@ -565,8 +564,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam
|
||||
"workspace_name": "string",
|
||||
"workspace_owner_avatar_url": "string",
|
||||
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
|
||||
"workspace_owner_name": "string",
|
||||
"workspace_owner_username": "string"
|
||||
"workspace_owner_name": "string"
|
||||
},
|
||||
"name": "string",
|
||||
"next_start_at": "2019-08-24T14:15:22Z",
|
||||
@@ -876,8 +874,7 @@ of the template will be used.
|
||||
"workspace_name": "string",
|
||||
"workspace_owner_avatar_url": "string",
|
||||
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
|
||||
"workspace_owner_name": "string",
|
||||
"workspace_owner_username": "string"
|
||||
"workspace_owner_name": "string"
|
||||
},
|
||||
"name": "string",
|
||||
"next_start_at": "2019-08-24T14:15:22Z",
|
||||
@@ -1147,8 +1144,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \
|
||||
"workspace_name": "string",
|
||||
"workspace_owner_avatar_url": "string",
|
||||
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
|
||||
"workspace_owner_name": "string",
|
||||
"workspace_owner_username": "string"
|
||||
"workspace_owner_name": "string"
|
||||
},
|
||||
"name": "string",
|
||||
"next_start_at": "2019-08-24T14:15:22Z",
|
||||
@@ -1433,8 +1429,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \
|
||||
"workspace_name": "string",
|
||||
"workspace_owner_avatar_url": "string",
|
||||
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
|
||||
"workspace_owner_name": "string",
|
||||
"workspace_owner_username": "string"
|
||||
"workspace_owner_name": "string"
|
||||
},
|
||||
"name": "string",
|
||||
"next_start_at": "2019-08-24T14:15:22Z",
|
||||
@@ -1834,8 +1829,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \
|
||||
"workspace_name": "string",
|
||||
"workspace_owner_avatar_url": "string",
|
||||
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
|
||||
"workspace_owner_name": "string",
|
||||
"workspace_owner_username": "string"
|
||||
"workspace_owner_name": "string"
|
||||
},
|
||||
"name": "string",
|
||||
"next_start_at": "2019-08-24T14:15:22Z",
|
||||
|
||||
@@ -9,7 +9,7 @@ RUN cargo install jj-cli typos-cli watchexec-cli
|
||||
FROM ubuntu:jammy@sha256:0e5e4a57c2499249aafc3b40fcd541e9a456aab7296681a3994d631587203f97 AS go
|
||||
|
||||
# Install Go manually, so that we can control the version
|
||||
ARG GO_VERSION=1.24.2
|
||||
ARG GO_VERSION=1.24.6
|
||||
|
||||
# Boring Go is needed to build FIPS-compliant binaries.
|
||||
RUN apt-get update && \
|
||||
|
||||
4
go.mod
4
go.mod
@@ -1,6 +1,6 @@
|
||||
module github.com/coder/coder/v2
|
||||
|
||||
go 1.24.2
|
||||
go 1.24.6
|
||||
|
||||
// Required until a v3 of chroma is created to lazily initialize all XML files.
|
||||
// None of our dependencies seem to use the registries anyways, so this
|
||||
@@ -58,7 +58,7 @@ replace github.com/imulab/go-scim/pkg/v2 => github.com/coder/go-scim/pkg/v2 v2.0
|
||||
// Adds support for a new Listener from a driver.Connector
|
||||
// This lets us use rotating authentication tokens for passwords in connection strings
|
||||
// which we use in the awsiamrds package.
|
||||
replace github.com/lib/pq => github.com/coder/pq v1.10.5-0.20240813183442-0c420cb5a048
|
||||
replace github.com/lib/pq => github.com/coder/pq v1.10.5-0.20250807075151-6ad9b0a25151
|
||||
|
||||
// Removes an init() function that causes terminal sequences to be printed to the web terminal when
|
||||
// used in conjunction with agent-exec. See https://github.com/coder/coder/pull/15817
|
||||
|
||||
4
go.sum
4
go.sum
@@ -907,8 +907,8 @@ github.com/coder/go-scim/pkg/v2 v2.0.0-20230221055123-1d63c1222136 h1:0RgB61LcNs
|
||||
github.com/coder/go-scim/pkg/v2 v2.0.0-20230221055123-1d63c1222136/go.mod h1:VkD1P761nykiq75dz+4iFqIQIZka189tx1BQLOp0Skc=
|
||||
github.com/coder/guts v1.5.0 h1:a94apf7xMf5jDdg1bIHzncbRiTn3+BvBZgrFSDbUnyI=
|
||||
github.com/coder/guts v1.5.0/go.mod h1:0Sbv5Kp83u1Nl7MIQiV2zmacJ3o02I341bkWkjWXSUQ=
|
||||
github.com/coder/pq v1.10.5-0.20240813183442-0c420cb5a048 h1:3jzYUlGH7ZELIH4XggXhnTnP05FCYiAFeQpoN+gNR5I=
|
||||
github.com/coder/pq v1.10.5-0.20240813183442-0c420cb5a048/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/coder/pq v1.10.5-0.20250807075151-6ad9b0a25151 h1:YAxwg3lraGNRwoQ18H7R7n+wsCqNve7Brdvj0F1rDnU=
|
||||
github.com/coder/pq v1.10.5-0.20250807075151-6ad9b0a25151/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs=
|
||||
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc=
|
||||
github.com/coder/preview v0.0.2-0.20250527172548-ab173d35040c h1:lPIImqcf46QcK3hYlr20xt2SG66IAAK/kfZdEhM6OJc=
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
# binary will be signed using ./sign_darwin.sh. Read that file for more details
|
||||
# on the requirements.
|
||||
#
|
||||
# If the --sign-gpg parameter is specified, the output binary will be signed using ./sign_with_gpg.sh.
|
||||
# Read that file for more details on the requirements.
|
||||
#
|
||||
# If the --agpl parameter is specified, builds only the AGPL-licensed code (no
|
||||
# Coder enterprise features).
|
||||
#
|
||||
@@ -41,6 +44,7 @@ slim="${CODER_SLIM_BUILD:-0}"
|
||||
agpl="${CODER_BUILD_AGPL:-0}"
|
||||
sign_darwin="${CODER_SIGN_DARWIN:-0}"
|
||||
sign_windows="${CODER_SIGN_WINDOWS:-0}"
|
||||
sign_gpg="${CODER_SIGN_GPG:-0}"
|
||||
boringcrypto=${CODER_BUILD_BORINGCRYPTO:-0}
|
||||
dylib=0
|
||||
windows_resources="${CODER_WINDOWS_RESOURCES:-0}"
|
||||
@@ -85,6 +89,10 @@ while true; do
|
||||
sign_windows=1
|
||||
shift
|
||||
;;
|
||||
--sign-gpg)
|
||||
sign_gpg=1
|
||||
shift
|
||||
;;
|
||||
--boringcrypto)
|
||||
boringcrypto=1
|
||||
shift
|
||||
@@ -319,4 +327,9 @@ if [[ "$sign_windows" == 1 ]] && [[ "$os" == "windows" ]]; then
|
||||
execrelative ./sign_windows.sh "$output_path" 1>&2
|
||||
fi
|
||||
|
||||
# Platform agnostic signing
|
||||
if [[ "$sign_gpg" == 1 ]]; then
|
||||
execrelative ./sign_with_gpg.sh "$output_path" 1>&2
|
||||
fi
|
||||
|
||||
echo "$output_path"
|
||||
|
||||
@@ -129,26 +129,9 @@ if [[ "$dry_run" == 0 ]] && [[ "${CODER_GPG_RELEASE_KEY_BASE64:-}" != "" ]]; the
|
||||
log "--- Signing checksums file"
|
||||
log
|
||||
|
||||
# Import the GPG key.
|
||||
old_gnupg_home="${GNUPGHOME:-}"
|
||||
gnupg_home_temp="$(mktemp -d)"
|
||||
export GNUPGHOME="$gnupg_home_temp"
|
||||
echo "$CODER_GPG_RELEASE_KEY_BASE64" | base64 -d | gpg --import 1>&2
|
||||
|
||||
# Sign the checksums file. This generates a file in the same directory and
|
||||
# with the same name as the checksums file but ending in ".asc".
|
||||
#
|
||||
# We pipe `true` into `gpg` so that it never tries to be interactive (i.e.
|
||||
# ask for a passphrase). The key we import above is not password protected.
|
||||
true | gpg --detach-sign --armor "${temp_dir}/${checksum_file}" 1>&2
|
||||
|
||||
rm -rf "$gnupg_home_temp"
|
||||
unset GNUPGHOME
|
||||
if [[ "$old_gnupg_home" != "" ]]; then
|
||||
export GNUPGHOME="$old_gnupg_home"
|
||||
fi
|
||||
|
||||
execrelative ../sign_with_gpg.sh "${temp_dir}/${checksum_file}"
|
||||
signed_checksum_path="${temp_dir}/${checksum_file}.asc"
|
||||
|
||||
if [[ ! -e "$signed_checksum_path" ]]; then
|
||||
log "Signed checksum file not found: ${signed_checksum_path}"
|
||||
log
|
||||
|
||||
59
scripts/sign_with_gpg.sh
Executable file
59
scripts/sign_with_gpg.sh
Executable file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# This script signs a given binary using GPG.
|
||||
# It expects the binary to be signed as the first argument.
|
||||
#
|
||||
# Usage: ./sign_with_gpg.sh path/to/binary
|
||||
#
|
||||
# On success, the input file will be signed using the GPG key and the signature output file will moved to /site/out/bin/ (happens in the Makefile)
|
||||
#
|
||||
# Depends on the GPG utility. Requires the following environment variables to be set:
|
||||
# - $CODER_GPG_RELEASE_KEY_BASE64: The base64 encoded private key to use.
|
||||
|
||||
set -euo pipefail
|
||||
# shellcheck source=scripts/lib.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
|
||||
requiredenvs CODER_GPG_RELEASE_KEY_BASE64
|
||||
|
||||
FILE_TO_SIGN="$1"
|
||||
|
||||
if [[ -z "$FILE_TO_SIGN" ]]; then
|
||||
error "Usage: $0 <file_to_sign>"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$FILE_TO_SIGN" ]]; then
|
||||
error "File not found: $FILE_TO_SIGN"
|
||||
fi
|
||||
|
||||
# Import the GPG key.
|
||||
old_gnupg_home="${GNUPGHOME:-}"
|
||||
gnupg_home_temp="$(mktemp -d)"
|
||||
export GNUPGHOME="$gnupg_home_temp"
|
||||
|
||||
# Ensure GPG uses the temporary directory
|
||||
echo "$CODER_GPG_RELEASE_KEY_BASE64" | base64 -d | gpg --homedir "$gnupg_home_temp" --import 1>&2
|
||||
|
||||
# Sign the binary. This generates a file in the same directory and
|
||||
# with the same name as the binary but ending in ".asc".
|
||||
#
|
||||
# We pipe `true` into `gpg` so that it never tries to be interactive (i.e.
|
||||
# ask for a passphrase). The key we import above is not password protected.
|
||||
true | gpg --homedir "$gnupg_home_temp" --detach-sign --armor "$FILE_TO_SIGN" 1>&2
|
||||
|
||||
# Verify the signature and capture the exit status
|
||||
gpg --homedir "$gnupg_home_temp" --verify "${FILE_TO_SIGN}.asc" "$FILE_TO_SIGN" 1>&2
|
||||
verification_result=$?
|
||||
|
||||
# Clean up the temporary GPG home
|
||||
rm -rf "$gnupg_home_temp"
|
||||
unset GNUPGHOME
|
||||
if [[ "$old_gnupg_home" != "" ]]; then
|
||||
export GNUPGHOME="$old_gnupg_home"
|
||||
fi
|
||||
|
||||
if [[ $verification_result -eq 0 ]]; then
|
||||
echo "${FILE_TO_SIGN}.asc"
|
||||
else
|
||||
error "Signature verification failed!"
|
||||
fi
|
||||
@@ -1165,7 +1165,7 @@ class ApiMethods {
|
||||
)
|
||||
) {
|
||||
const { job } = await this.getWorkspaceBuildByNumber(
|
||||
build.workspace_owner_username,
|
||||
build.workspace_owner_name,
|
||||
build.workspace_name,
|
||||
build.build_number,
|
||||
);
|
||||
|
||||
@@ -279,7 +279,7 @@ const updateWorkspaceBuild = async (
|
||||
queryClient: QueryClient,
|
||||
) => {
|
||||
const workspaceKey = workspaceByOwnerAndNameKey(
|
||||
build.workspace_owner_username,
|
||||
build.workspace_owner_name,
|
||||
build.workspace_name,
|
||||
);
|
||||
const previousData = queryClient.getQueryData<Workspace>(workspaceKey);
|
||||
|
||||
3
site/src/api/typesGenerated.ts
generated
3
site/src/api/typesGenerated.ts
generated
@@ -3622,8 +3622,7 @@ export interface WorkspaceBuild {
|
||||
readonly workspace_id: string;
|
||||
readonly workspace_name: string;
|
||||
readonly workspace_owner_id: string;
|
||||
readonly workspace_owner_name?: string;
|
||||
readonly workspace_owner_username: string;
|
||||
readonly workspace_owner_name: string;
|
||||
readonly workspace_owner_avatar_url?: string;
|
||||
readonly template_version_id: string;
|
||||
readonly template_version_name: string;
|
||||
|
||||
@@ -12,27 +12,30 @@ const meta: Meta<typeof FeatureStageBadge> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FeatureStageBadge>;
|
||||
|
||||
export const MediumBeta: Story = {
|
||||
args: {
|
||||
size: "md",
|
||||
},
|
||||
};
|
||||
|
||||
export const SmallBeta: Story = {
|
||||
args: {
|
||||
size: "sm",
|
||||
contentType: "beta",
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeBeta: Story = {
|
||||
args: {
|
||||
size: "lg",
|
||||
},
|
||||
};
|
||||
|
||||
export const MediumExperimental: Story = {
|
||||
export const MediumBeta: Story = {
|
||||
args: {
|
||||
size: "md",
|
||||
contentType: "experimental",
|
||||
contentType: "beta",
|
||||
},
|
||||
};
|
||||
|
||||
export const SmallEarlyAccess: Story = {
|
||||
args: {
|
||||
size: "sm",
|
||||
contentType: "early_access",
|
||||
},
|
||||
};
|
||||
|
||||
export const MediumEarlyAccess: Story = {
|
||||
args: {
|
||||
size: "md",
|
||||
contentType: "early_access",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import Link from "@mui/material/Link";
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
import { HelpTooltipContent } from "components/HelpTooltip/HelpTooltip";
|
||||
import { Popover, PopoverTrigger } from "components/deprecated/Popover/Popover";
|
||||
import { Link } from "components/Link/Link";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import type { FC, HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { docs } from "utils/docs";
|
||||
|
||||
/**
|
||||
@@ -11,132 +14,73 @@ import { docs } from "utils/docs";
|
||||
* ensure that we can't accidentally make typos when writing the badge text.
|
||||
*/
|
||||
export const featureStageBadgeTypes = {
|
||||
early_access: "early access",
|
||||
beta: "beta",
|
||||
experimental: "experimental",
|
||||
} as const satisfies Record<string, ReactNode>;
|
||||
|
||||
type FeatureStageBadgeProps = Readonly<
|
||||
Omit<HTMLAttributes<HTMLSpanElement>, "children"> & {
|
||||
contentType: keyof typeof featureStageBadgeTypes;
|
||||
labelText?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
showTooltip?: boolean;
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
>;
|
||||
|
||||
const badgeColorClasses = {
|
||||
early_access: "bg-surface-orange text-content-warning",
|
||||
beta: "bg-surface-sky text-highlight-sky",
|
||||
} as const;
|
||||
|
||||
const badgeSizeClasses = {
|
||||
sm: "text-xs font-medium px-2 py-1",
|
||||
md: "text-base px-2 py-1",
|
||||
} as const;
|
||||
|
||||
export const FeatureStageBadge: FC<FeatureStageBadgeProps> = ({
|
||||
contentType,
|
||||
labelText = "",
|
||||
size = "md",
|
||||
showTooltip = true, // This is a temporary until the deprecated popover is removed
|
||||
className,
|
||||
...delegatedProps
|
||||
}) => {
|
||||
const colorClasses = badgeColorClasses[contentType];
|
||||
const sizeClasses = badgeSizeClasses[size];
|
||||
|
||||
return (
|
||||
<Popover mode="hover">
|
||||
<PopoverTrigger>
|
||||
{({ isOpen }) => (
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
css={[
|
||||
styles.badge,
|
||||
size === "sm" && styles.badgeSmallText,
|
||||
size === "lg" && styles.badgeLargeText,
|
||||
isOpen && styles.badgeHover,
|
||||
]}
|
||||
className={cn(
|
||||
"block max-w-fit cursor-default flex-shrink-0 leading-none whitespace-nowrap border rounded-md transition-colors duration-200 ease-in-out bg-transparent border-solid border-transparent",
|
||||
sizeClasses,
|
||||
colorClasses,
|
||||
className,
|
||||
)}
|
||||
{...delegatedProps}
|
||||
>
|
||||
<span style={visuallyHidden}> (This is a</span>
|
||||
<span className="sr-only"> (This is a</span>
|
||||
<span className="first-letter:uppercase">
|
||||
{labelText && `${labelText} `}
|
||||
{featureStageBadgeTypes[contentType]}
|
||||
</span>
|
||||
<span style={visuallyHidden}> feature)</span>
|
||||
<span className="sr-only"> feature)</span>
|
||||
</span>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
|
||||
{showTooltip && (
|
||||
<HelpTooltipContent
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||
transformOrigin={{ vertical: "top", horizontal: "center" }}
|
||||
>
|
||||
<p css={styles.tooltipDescription}>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="start" className="max-w-xs text-sm">
|
||||
<p className="m-0">
|
||||
This feature has not yet reached general availability (GA).
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href={docs("/install/releases/feature-stages")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
css={styles.tooltipLink}
|
||||
className="font-semibold"
|
||||
>
|
||||
Learn about feature stages
|
||||
<span style={visuallyHidden}> (link opens in new tab)</span>
|
||||
<span className="sr-only"> (link opens in new tab)</span>
|
||||
</Link>
|
||||
</HelpTooltipContent>
|
||||
)}
|
||||
</Popover>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
badge: (theme) => ({
|
||||
// Base type is based on a span so that the element can be placed inside
|
||||
// more types of HTML elements without creating invalid markdown, but we
|
||||
// still want the default display behavior to be div-like
|
||||
display: "block",
|
||||
maxWidth: "fit-content",
|
||||
|
||||
// Base style assumes that medium badges will be the default
|
||||
fontSize: "0.75rem",
|
||||
|
||||
cursor: "default",
|
||||
flexShrink: 0,
|
||||
padding: "4px 8px",
|
||||
lineHeight: 1,
|
||||
whiteSpace: "nowrap",
|
||||
border: `1px solid ${theme.branding.featureStage.border}`,
|
||||
color: theme.branding.featureStage.text,
|
||||
backgroundColor: theme.branding.featureStage.background,
|
||||
borderRadius: "6px",
|
||||
transition:
|
||||
"color 0.2s ease-in-out, border-color 0.2s ease-in-out, background-color 0.2s ease-in-out",
|
||||
}),
|
||||
|
||||
badgeHover: (theme) => ({
|
||||
color: theme.branding.featureStage.hover.text,
|
||||
borderColor: theme.branding.featureStage.hover.border,
|
||||
backgroundColor: theme.branding.featureStage.hover.background,
|
||||
}),
|
||||
|
||||
badgeLargeText: {
|
||||
fontSize: "1rem",
|
||||
},
|
||||
|
||||
badgeSmallText: {
|
||||
// Have to beef up font weight so that the letters still maintain the
|
||||
// same relative thickness as all our other main UI text
|
||||
fontWeight: 500,
|
||||
fontSize: "0.625rem",
|
||||
},
|
||||
|
||||
tooltipTitle: (theme) => ({
|
||||
color: theme.palette.text.primary,
|
||||
fontWeight: 600,
|
||||
fontFamily: "inherit",
|
||||
fontSize: 18,
|
||||
margin: 0,
|
||||
lineHeight: 1,
|
||||
paddingBottom: "8px",
|
||||
}),
|
||||
|
||||
tooltipDescription: {
|
||||
margin: 0,
|
||||
lineHeight: 1.4,
|
||||
paddingBottom: "8px",
|
||||
},
|
||||
|
||||
tooltipLink: {
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
} as const satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
@@ -562,11 +562,11 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||
|
||||
const classNames = {
|
||||
paper: (css, theme) => css`
|
||||
padding: 0;
|
||||
width: 404px;
|
||||
color: ${theme.palette.text.secondary};
|
||||
margin-top: 4px;
|
||||
`,
|
||||
padding: 0;
|
||||
width: 404px;
|
||||
color: ${theme.palette.text.secondary};
|
||||
margin-top: 4px;
|
||||
`,
|
||||
} satisfies Record<string, ClassName>;
|
||||
|
||||
const styles = {
|
||||
|
||||
@@ -73,6 +73,9 @@ export const AgentSSHButton: FC<AgentSSHButtonProps> = ({
|
||||
>
|
||||
Connect via JetBrains Gateway
|
||||
</HelpTooltipLink>
|
||||
<HelpTooltipLink href={docs("/user-guides/desktop")}>
|
||||
Connect via Coder Desktop
|
||||
</HelpTooltipLink>
|
||||
<HelpTooltipLink href={docs("/user-guides/workspace-access#ssh")}>
|
||||
SSH configuration
|
||||
</HelpTooltipLink>
|
||||
|
||||
@@ -84,6 +84,7 @@ export const DynamicParameter: FC<DynamicParameterProps> = ({
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
isPreset={isPreset}
|
||||
/>
|
||||
) : (
|
||||
<ParameterField
|
||||
@@ -231,6 +232,7 @@ interface DebouncedParameterFieldProps {
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
id: string;
|
||||
isPreset?: boolean;
|
||||
}
|
||||
|
||||
const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
|
||||
@@ -239,6 +241,7 @@ const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
|
||||
onChange,
|
||||
disabled,
|
||||
id,
|
||||
isPreset,
|
||||
}) => {
|
||||
const [localValue, setLocalValue] = useState(
|
||||
value !== undefined ? value : validValue(parameter.value),
|
||||
@@ -251,19 +254,26 @@ const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
|
||||
|
||||
// This is necessary in the case of fields being set by preset parameters
|
||||
useEffect(() => {
|
||||
if (value !== undefined && value !== prevValueRef.current) {
|
||||
if (isPreset && value !== undefined && value !== prevValueRef.current) {
|
||||
setLocalValue(value);
|
||||
prevValueRef.current = value;
|
||||
}
|
||||
}, [value]);
|
||||
}, [value, isPreset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevDebouncedValueRef.current !== undefined) {
|
||||
// Only call onChangeEvent if debouncedLocalValue is different from the previously committed value
|
||||
// and it's not the initial undefined state.
|
||||
if (
|
||||
prevDebouncedValueRef.current !== undefined &&
|
||||
prevDebouncedValueRef.current !== debouncedLocalValue
|
||||
) {
|
||||
onChangeEvent(debouncedLocalValue);
|
||||
}
|
||||
|
||||
// Update the ref to the current debounced value for the next comparison
|
||||
prevDebouncedValueRef.current = debouncedLocalValue;
|
||||
}, [debouncedLocalValue, onChangeEvent]);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const resizeTextarea = useEffectEvent(() => {
|
||||
@@ -513,7 +523,9 @@ const ParameterField: FC<ParameterFieldProps> = ({
|
||||
max={parameter.validations[0]?.validation_max ?? 100}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="w-4 font-medium">{parameter.value.value}</span>
|
||||
<span className="w-4 font-medium">
|
||||
{Number.isFinite(Number(value)) ? value : "0"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case "error":
|
||||
|
||||
@@ -3,12 +3,12 @@ import type { FriendlyDiagnostic, PreviewParameter } from "api/typesGenerated";
|
||||
import { Alert } from "components/Alert/Alert";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { Badge } from "components/Badge/Badge";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
|
||||
import { Input } from "components/Input/Input";
|
||||
import { Label } from "components/Label/Label";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { Pill } from "components/Pill/Pill";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -353,21 +353,39 @@ export const CreateWorkspacePageViewExperimental: FC<
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 max-w-screen-md mx-auto">
|
||||
<header className="flex flex-col items-start gap-3 mt-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
variant="icon"
|
||||
size="md"
|
||||
src={template.icon}
|
||||
fallback={template.name}
|
||||
/>
|
||||
<p className="text-base font-medium m-0">
|
||||
{template.display_name.length > 0
|
||||
? template.display_name
|
||||
: template.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 justify-between w-full">
|
||||
<span className="flex items-center gap-2">
|
||||
<Avatar
|
||||
variant="icon"
|
||||
size="md"
|
||||
src={template.icon}
|
||||
fallback={template.name}
|
||||
/>
|
||||
<p className="text-base font-medium m-0">
|
||||
{template.display_name.length > 0
|
||||
? template.display_name
|
||||
: template.name}
|
||||
</p>
|
||||
{template.deprecated && (
|
||||
<Badge variant="warning" size="sm">
|
||||
Deprecated
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
{experimentalFormContext && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={experimentalFormContext.toggleOptedOut}
|
||||
>
|
||||
<Undo2 />
|
||||
Classic workspace creation
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex flex-row items-center gap-2">
|
||||
<h1 className="text-3xl font-semibold m-0">New workspace</h1>
|
||||
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -389,19 +407,11 @@ export const CreateWorkspacePageViewExperimental: FC<
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
|
||||
{template.deprecated && <Pill type="warning">Deprecated</Pill>}
|
||||
|
||||
{experimentalFormContext && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={experimentalFormContext.toggleOptedOut}
|
||||
>
|
||||
<Undo2 />
|
||||
Use the classic workspace creation flow
|
||||
</Button>
|
||||
)}
|
||||
<FeatureStageBadge
|
||||
contentType={"early_access"}
|
||||
size="sm"
|
||||
labelText="Dynamic parameters"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<form
|
||||
@@ -555,7 +565,7 @@ export const CreateWorkspacePageViewExperimental: FC<
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Label className="text-sm">Preset</Label>
|
||||
<FeatureStageBadge contentType={"beta"} size="md" />
|
||||
<FeatureStageBadge contentType={"beta"} size="sm" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="max-w-lg">
|
||||
|
||||
@@ -53,7 +53,7 @@ export const Section: FC<SectionProps> = ({
|
||||
{featureStage && (
|
||||
<FeatureStageBadge
|
||||
contentType={featureStage}
|
||||
size="lg"
|
||||
size="md"
|
||||
css={{ marginBottom: "5px" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -205,7 +205,7 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{`coder rm ${`${build.workspace_owner_username}/${build.workspace_name}`} --orphan`}
|
||||
{`coder rm ${`${build.workspace_owner_name}/${build.workspace_name}`} --orphan`}
|
||||
</code>{" "}
|
||||
to delete the workspace skipping resource destruction.
|
||||
</div>
|
||||
|
||||
@@ -117,18 +117,18 @@ export const WorkspaceParametersPageView: FC<
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<header className="flex flex-col items-start gap-2">
|
||||
<span className="flex flex-row justify-between items-center gap-2">
|
||||
<span className="flex flex-row justify-between w-full items-center gap-2">
|
||||
<h1 className="text-3xl m-0">Workspace parameters</h1>
|
||||
{experimentalFormContext && (
|
||||
<ShadcnButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={experimentalFormContext.toggleOptedOut}
|
||||
>
|
||||
Try out the new workspace parameters ✨
|
||||
</ShadcnButton>
|
||||
)}
|
||||
</span>
|
||||
{experimentalFormContext && (
|
||||
<ShadcnButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={experimentalFormContext.toggleOptedOut}
|
||||
>
|
||||
Try out the new workspace parameters ✨
|
||||
</ShadcnButton>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{submitError && !isApiValidationError(submitError) ? (
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import {
|
||||
@@ -26,6 +27,7 @@ import { useMutation, useQuery } from "react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { docs } from "utils/docs";
|
||||
import { pageTitle } from "utils/page";
|
||||
import type { AutofillBuildParameter } from "utils/richParameters";
|
||||
import {
|
||||
type WorkspacePermissions,
|
||||
workspaceChecks,
|
||||
@@ -39,11 +41,27 @@ const WorkspaceParametersPageExperimental: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const experimentalFormContext = useContext(ExperimentalFormContext);
|
||||
|
||||
// autofill the form with the workspace build parameters from the latest build
|
||||
const {
|
||||
data: latestBuildParameters,
|
||||
isLoading: latestBuildParametersLoading,
|
||||
} = useQuery({
|
||||
queryKey: ["workspaceBuilds", workspace.latest_build.id, "parameters"],
|
||||
queryFn: () => API.getWorkspaceBuildParameters(workspace.latest_build.id),
|
||||
});
|
||||
|
||||
const [latestResponse, setLatestResponse] =
|
||||
useState<DynamicParametersResponse | null>(null);
|
||||
const wsResponseId = useRef<number>(-1);
|
||||
const ws = useRef<WebSocket | null>(null);
|
||||
const [wsError, setWsError] = useState<Error | null>(null);
|
||||
const initialParamsSentRef = useRef(false);
|
||||
|
||||
const autofillParameters: AutofillBuildParameter[] =
|
||||
latestBuildParameters?.map((p) => ({
|
||||
...p,
|
||||
source: "active_build",
|
||||
})) ?? [];
|
||||
|
||||
const sendMessage = useEffectEvent((formValues: Record<string, string>) => {
|
||||
const request: DynamicParametersRequest = {
|
||||
@@ -57,11 +75,34 @@ const WorkspaceParametersPageExperimental: FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
// On page load, sends initial workspace build parameters to the websocket.
|
||||
// This ensures the backend has the form's complete initial state,
|
||||
// vital for rendering dynamic UI elements dependent on initial parameter values.
|
||||
const sendInitialParameters = useEffectEvent(() => {
|
||||
if (initialParamsSentRef.current) return;
|
||||
if (autofillParameters.length === 0) return;
|
||||
|
||||
const initialParamsToSend: Record<string, string> = {};
|
||||
for (const param of autofillParameters) {
|
||||
if (param.name && param.value) {
|
||||
initialParamsToSend[param.name] = param.value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(initialParamsToSend).length === 0) return;
|
||||
|
||||
sendMessage(initialParamsToSend);
|
||||
initialParamsSentRef.current = true;
|
||||
});
|
||||
|
||||
const onMessage = useEffectEvent((response: DynamicParametersResponse) => {
|
||||
if (latestResponse && latestResponse?.id >= response.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!initialParamsSentRef.current && response.parameters?.length > 0) {
|
||||
sendInitialParameters();
|
||||
}
|
||||
|
||||
setLatestResponse(response);
|
||||
});
|
||||
|
||||
@@ -149,6 +190,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
|
||||
const error = wsError || updateParameters.error;
|
||||
|
||||
if (
|
||||
latestBuildParametersLoading ||
|
||||
!latestResponse ||
|
||||
(ws.current && ws.current.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
@@ -162,39 +204,46 @@ const WorkspaceParametersPageExperimental: FC = () => {
|
||||
</Helmet>
|
||||
|
||||
<header className="flex flex-col items-start gap-2">
|
||||
<span className="flex flex-row items-center gap-2">
|
||||
<h1 className="text-3xl m-0">Workspace parameters</h1>
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleHelp className="size-icon-xs text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-sm">
|
||||
Dynamic Parameters enhances Coder's existing parameter system
|
||||
with real-time validation, conditional parameter behavior, and
|
||||
richer input types.
|
||||
<br />
|
||||
<Link
|
||||
href={docs(
|
||||
"/admin/templates/extending-templates/parameters#enable-dynamic-parameters-early-access",
|
||||
)}
|
||||
>
|
||||
View docs
|
||||
</Link>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<span className="flex flex-row items-center gap-2 justify-between w-full">
|
||||
<span className="flex flex-row items-center gap-2">
|
||||
<h1 className="text-3xl m-0">Workspace parameters</h1>
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleHelp className="size-icon-xs text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-sm">
|
||||
Dynamic Parameters enhances Coder's existing parameter system
|
||||
with real-time validation, conditional parameter behavior, and
|
||||
richer input types.
|
||||
<br />
|
||||
<Link
|
||||
href={docs(
|
||||
"/admin/templates/extending-templates/parameters#enable-dynamic-parameters-early-access",
|
||||
)}
|
||||
>
|
||||
View docs
|
||||
</Link>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
{experimentalFormContext && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={experimentalFormContext.toggleOptedOut}
|
||||
>
|
||||
<Undo2 />
|
||||
Classic workspace parameters
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
{experimentalFormContext && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={experimentalFormContext.toggleOptedOut}
|
||||
>
|
||||
<Undo2 />
|
||||
Use the classic workspace parameters
|
||||
</Button>
|
||||
)}
|
||||
<FeatureStageBadge
|
||||
contentType={"early_access"}
|
||||
size="sm"
|
||||
labelText="Dynamic parameters"
|
||||
/>
|
||||
</header>
|
||||
|
||||
{Boolean(error) && <ErrorAlert error={error} />}
|
||||
@@ -202,6 +251,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
|
||||
{sortedParams.length > 0 ? (
|
||||
<WorkspaceParametersPageViewExperimental
|
||||
workspace={workspace}
|
||||
autofillParameters={autofillParameters}
|
||||
canChangeVersions={canChangeVersions}
|
||||
parameters={sortedParams}
|
||||
diagnostics={latestResponse.diagnostics}
|
||||
|
||||
@@ -16,9 +16,11 @@ import {
|
||||
} from "modules/workspaces/DynamicParameter/DynamicParameter";
|
||||
import type { FC } from "react";
|
||||
import { docs } from "utils/docs";
|
||||
import type { AutofillBuildParameter } from "utils/richParameters";
|
||||
|
||||
type WorkspaceParametersPageViewExperimentalProps = {
|
||||
workspace: Workspace;
|
||||
autofillParameters: AutofillBuildParameter[];
|
||||
parameters: PreviewParameter[];
|
||||
diagnostics: PreviewParameter["diagnostics"];
|
||||
canChangeVersions: boolean;
|
||||
@@ -34,6 +36,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
|
||||
WorkspaceParametersPageViewExperimentalProps
|
||||
> = ({
|
||||
workspace,
|
||||
autofillParameters,
|
||||
parameters,
|
||||
diagnostics,
|
||||
canChangeVersions,
|
||||
@@ -42,17 +45,32 @@ export const WorkspaceParametersPageViewExperimental: FC<
|
||||
sendMessage,
|
||||
onCancel,
|
||||
}) => {
|
||||
const autofillByName = Object.fromEntries(
|
||||
autofillParameters.map((param) => [param.name, param]),
|
||||
);
|
||||
const initialTouched = parameters.reduce(
|
||||
(touched, parameter) => {
|
||||
if (autofillByName[parameter.name] !== undefined) {
|
||||
touched[parameter.name] = true;
|
||||
}
|
||||
return touched;
|
||||
},
|
||||
{} as Record<string, boolean>,
|
||||
);
|
||||
const form = useFormik({
|
||||
onSubmit,
|
||||
initialValues: {
|
||||
rich_parameter_values: getInitialParameterValues(parameters),
|
||||
rich_parameter_values: getInitialParameterValues(
|
||||
parameters,
|
||||
autofillParameters,
|
||||
),
|
||||
},
|
||||
initialTouched,
|
||||
validationSchema: useValidationSchemaForDynamicParameters(parameters),
|
||||
enableReinitialize: false,
|
||||
validateOnChange: true,
|
||||
validateOnBlur: true,
|
||||
});
|
||||
|
||||
// Group parameters by ephemeral status
|
||||
const ephemeralParameters = parameters.filter((p) => p.ephemeral);
|
||||
const standardParameters = parameters.filter((p) => !p.ephemeral);
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type Update,
|
||||
} from "./BatchUpdateConfirmation";
|
||||
|
||||
const workspaces = [
|
||||
const workspaces: Workspace[] = [
|
||||
{ ...MockRunningOutdatedWorkspace, id: "1" },
|
||||
{ ...MockDormantOutdatedWorkspace, id: "2" },
|
||||
{ ...MockOutdatedWorkspace, id: "3" },
|
||||
|
||||
@@ -1289,7 +1289,7 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = {
|
||||
updated_at: "2022-05-17T17:39:01.382927298Z",
|
||||
workspace_name: "test-workspace",
|
||||
workspace_owner_id: MockUserOwner.id,
|
||||
workspace_owner_username: MockUserOwner.username,
|
||||
workspace_owner_name: MockUserOwner.username,
|
||||
workspace_owner_avatar_url: MockUserOwner.avatar_url,
|
||||
workspace_id: "759f1d46-3174-453d-aa60-980a9c1442f3",
|
||||
deadline: "2022-05-17T23:39:00.00Z",
|
||||
@@ -1317,7 +1317,7 @@ const MockWorkspaceBuildAutostart: TypesGen.WorkspaceBuild = {
|
||||
updated_at: "2022-05-17T17:39:01.382927298Z",
|
||||
workspace_name: "test-workspace",
|
||||
workspace_owner_id: MockUserOwner.id,
|
||||
workspace_owner_username: MockUserOwner.username,
|
||||
workspace_owner_name: MockUserOwner.username,
|
||||
workspace_owner_avatar_url: MockUserOwner.avatar_url,
|
||||
workspace_id: "759f1d46-3174-453d-aa60-980a9c1442f3",
|
||||
deadline: "2022-05-17T23:39:00.00Z",
|
||||
@@ -1341,7 +1341,7 @@ const MockWorkspaceBuildAutostop: TypesGen.WorkspaceBuild = {
|
||||
updated_at: "2022-05-17T17:39:01.382927298Z",
|
||||
workspace_name: "test-workspace",
|
||||
workspace_owner_id: MockUserOwner.id,
|
||||
workspace_owner_username: MockUserOwner.username,
|
||||
workspace_owner_name: MockUserOwner.username,
|
||||
workspace_owner_avatar_url: MockUserOwner.avatar_url,
|
||||
workspace_id: "759f1d46-3174-453d-aa60-980a9c1442f3",
|
||||
deadline: "2022-05-17T23:39:00.00Z",
|
||||
@@ -1367,7 +1367,7 @@ export const MockFailedWorkspaceBuild = (
|
||||
updated_at: "2022-05-17T17:39:01.382927298Z",
|
||||
workspace_name: "test-workspace",
|
||||
workspace_owner_id: MockUserOwner.id,
|
||||
workspace_owner_username: MockUserOwner.username,
|
||||
workspace_owner_name: MockUserOwner.username,
|
||||
workspace_owner_avatar_url: MockUserOwner.avatar_url,
|
||||
workspace_id: "759f1d46-3174-453d-aa60-980a9c1442f3",
|
||||
deadline: "2022-05-17T23:39:00.00Z",
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"pycharm.svg",
|
||||
"python.svg",
|
||||
"pytorch.svg",
|
||||
"rdp.svg",
|
||||
"rider.svg",
|
||||
"rockylinux.svg",
|
||||
"rstudio.svg",
|
||||
@@ -105,6 +106,7 @@
|
||||
"vsphere.svg",
|
||||
"webstorm.svg",
|
||||
"widgets.svg",
|
||||
"windows.svg",
|
||||
"windsurf.svg",
|
||||
"zed.svg"
|
||||
]
|
||||
|
||||
@@ -1,4 +1,25 @@
|
||||
<?xml version="1.0"?>
|
||||
<!-- MIT License
|
||||
|
||||
Copyright (c) 2023 LobeHub
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.-->
|
||||
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<title>Cursor</title>
|
||||
<defs>
|
||||
@@ -24,24 +45,4 @@
|
||||
<path d="m22.35,6l-10.42,6l-10.43,-6l20.85,0z" fill="#FFF" id="svg_5"/>
|
||||
</g>
|
||||
</svg>
|
||||
<!-- MIT License
|
||||
|
||||
Copyright (c) 2023 LobeHub
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.-->
|
||||
|
||||
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
35
site/static/icon/rdp.svg
Normal file
35
site/static/icon/rdp.svg
Normal file
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" fill="none" viewBox="0 0 512 512">
|
||||
<g transform="translate(256 256)scale(8.96)">
|
||||
<linearGradient id="a" x1="6.221" x2="37.408" y1="6.221" y2="37.408" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" style="stop-color:#f0f0f0;stop-opacity:1"/>
|
||||
<stop offset="100%" style="stop-color:#bbc1c4;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
<path d="M24 5C13.507 5 5 13.507 5 24s8.507 19 19 19 19-8.507 19-19S34.493 5 24 5" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#a);fill-rule:nonzero;opacity:1" transform="translate(-24 -24)"/>
|
||||
</g>
|
||||
<g transform="translate(256 256)scale(8.96)">
|
||||
<linearGradient id="b" x1="12.859" x2="35.224" y1="12.859" y2="35.224" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" style="stop-color:#e04f12;stop-opacity:1"/>
|
||||
<stop offset="61.5%" style="stop-color:#ce400d;stop-opacity:1"/>
|
||||
<stop offset="100%" style="stop-color:#c03409;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
<path d="M24 40c8.837 0 16-7.163 16-16S32.837 8 24 8 8 15.163 8 24s7.163 16 16 16" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#b);fill-rule:nonzero;opacity:1" transform="translate(-24 -24)"/>
|
||||
</g>
|
||||
<path d="m30.414 20 3.89-3.89c.708-.708.449-1.772 0-2.221l-2.195-2.195a1.573 1.573 0 0 0-2.218.001l-7.194 7.194c-.549.549-.752 1.469-.001 2.22l7.196 7.196c.76.76 1.592.625 2.218-.001l2.194-2.194c.707-.707.716-1.505.001-2.22z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#000;fill-rule:nonzero;opacity:.05" transform="translate(40.96 40.96)scale(8.96)"/>
|
||||
<path d="m33.951 14.244-2.195-2.195a1.07 1.07 0 0 0-1.511 0l-7.195 7.195c-.386.386-.487 1.025 0 1.512l7.196 7.196c.491.491 1.087.424 1.511 0l2.195-2.195c.464-.464.469-1.044 0-1.512L29.707 20l4.244-4.244c.465-.465.335-1.177 0-1.512" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#000;fill-rule:nonzero;opacity:.07" transform="translate(40.96 40.96)scale(8.96)"/>
|
||||
<path d="m17.586 28-3.89 3.89c-.708.708-.449 1.772 0 2.221l2.195 2.195c.611.609 1.606.61 2.218-.001l7.194-7.194c.549-.549.752-1.469.001-2.22l-7.196-7.196c-.76-.76-1.592-.625-2.218.001l-2.194 2.194c-.707.707-.716 1.505-.001 2.22z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#000;fill-rule:nonzero;opacity:.05" transform="translate(40.96 40.96)scale(8.96)"/>
|
||||
<path d="m14.049 33.756 2.195 2.195a1.07 1.07 0 0 0 1.511 0l7.195-7.195c.386-.386.487-1.025 0-1.512l-7.196-7.196c-.491-.491-1.087-.424-1.511 0l-2.195 2.195c-.464.464-.469 1.044 0 1.512L18.293 28l-4.244 4.244c-.465.465-.335 1.177 0 1.512" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#000;fill-rule:nonzero;opacity:.07" transform="translate(40.96 40.96)scale(8.96)"/>
|
||||
<g transform="translate(296.83 220.16)scale(8.96)">
|
||||
<linearGradient id="c" x1="23.755" x2="38.564" y1="9.93" y2="33.557" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" style="stop-color:#fcfcfc;stop-opacity:1"/>
|
||||
<stop offset="100%" style="stop-color:#c3c9cd;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
<path d="m33.598 14.598-2.196-2.196a.57.57 0 0 0-.804 0l-7.196 7.196a.57.57 0 0 0 0 .804l7.196 7.196a.57.57 0 0 0 .804 0l2.196-2.196a.57.57 0 0 0 0-.804L29 20l4.598-4.598a.57.57 0 0 0 0-.804" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#c);fill-rule:nonzero;opacity:1" transform="translate(-28.5 -20)"/>
|
||||
</g>
|
||||
<g transform="translate(215.17 291.84)scale(8.96)">
|
||||
<linearGradient id="d" x1="11.438" x2="26.247" y1="17.637" y2="41.265" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" style="stop-color:#fcfcfc;stop-opacity:1"/>
|
||||
<stop offset="100%" style="stop-color:#c3c9cd;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
<path d="M14.402 23.402 19 28l-4.598 4.598a.57.57 0 0 0 0 .804l2.196 2.196a.57.57 0 0 0 .804 0l7.196-7.196a.57.57 0 0 0 0-.804l-7.196-7.196a.57.57 0 0 0-.804 0l-2.196 2.196a.57.57 0 0 0 0 .804" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#d);fill-rule:nonzero;opacity:1" transform="translate(-19.5 -28)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
29
site/static/icon/windows.svg
Normal file
29
site/static/icon/windows.svg
Normal file
@@ -0,0 +1,29 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_8)">
|
||||
<rect width="30" height="30" fill="url(#paint0_linear_1_8)"/>
|
||||
<rect width="30" height="30" transform="translate(0 34)" fill="url(#paint1_linear_1_8)"/>
|
||||
<rect width="30" height="30" transform="translate(34)" fill="url(#paint2_linear_1_8)"/>
|
||||
<rect width="30" height="30" transform="translate(34 34)" fill="url(#paint3_linear_1_8)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_1_8" x1="0" y1="0" x2="30" y2="30" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#76F1FF"/>
|
||||
<stop offset="1" stop-color="#52D5FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_1_8" x1="0" y1="0" x2="30" y2="30" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#51BFEA"/>
|
||||
<stop offset="1" stop-color="#25AFFF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_1_8" x1="0" y1="0" x2="30" y2="30" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#5BDCFF"/>
|
||||
<stop offset="1" stop-color="#34BCFF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_1_8" x1="0" y1="0" x2="30" y2="30" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#39C1FF"/>
|
||||
<stop offset="1" stop-color="#0D9CFD"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_1_8">
|
||||
<rect width="64" height="64" rx="4" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
Reference in New Issue
Block a user