Skip to content

Commit 5b7ef4f

Browse files
committed
Merge branch 'main' into docs-preview-action
2 parents 6b4f62c + 114ba45 commit 5b7ef4f

File tree

18 files changed

+346
-108
lines changed

18 files changed

+346
-108
lines changed

.github/workflows/ci.yaml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,6 +1180,33 @@ jobs:
11801180
done
11811181
fi
11821182
1183+
- name: SBOM Generation and Attestation
1184+
if: github.ref == 'refs/heads/main'
1185+
env:
1186+
COSIGN_EXPERIMENTAL: 1
1187+
run: |
1188+
set -euxo pipefail
1189+
1190+
# Define image base and tags
1191+
IMAGE_BASE="ghcr.io/coder/coder-preview"
1192+
TAGS=("${{ steps.build-docker.outputs.tag }}" "main" "latest")
1193+
1194+
# Generate and attest SBOM for each tag
1195+
for tag in "${TAGS[@]}"; do
1196+
IMAGE="${IMAGE_BASE}:${tag}"
1197+
SBOM_FILE="coder_sbom_${tag//[:\/]/_}.spdx.json"
1198+
1199+
echo "Generating SBOM for image: ${IMAGE}"
1200+
syft "${IMAGE}" -o spdx-json > "${SBOM_FILE}"
1201+
1202+
echo "Attesting SBOM to image: ${IMAGE}"
1203+
cosign clean "${IMAGE}"
1204+
cosign attest --type spdxjson \
1205+
--predicate "${SBOM_FILE}" \
1206+
--yes \
1207+
"${IMAGE}"
1208+
done
1209+
11831210
# GitHub attestation provides SLSA provenance for the Docker images, establishing a verifiable
11841211
# record that these images were built in GitHub Actions with specific inputs and environment.
11851212
# This complements our existing cosign attestations which focus on SBOMs.

.github/workflows/release.yaml

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,39 @@ jobs:
496496
env:
497497
CODER_BASE_IMAGE_TAG: ${{ steps.image-base-tag.outputs.tag }}
498498

499+
- name: SBOM Generation and Attestation
500+
if: ${{ !inputs.dry_run }}
501+
env:
502+
COSIGN_EXPERIMENTAL: "1"
503+
run: |
504+
set -euxo pipefail
505+
506+
# Generate SBOM for multi-arch image with version in filename
507+
echo "Generating SBOM for multi-arch image: ${{ steps.build_docker.outputs.multiarch_image }}"
508+
syft "${{ steps.build_docker.outputs.multiarch_image }}" -o spdx-json > coder_${{ steps.version.outputs.version }}_sbom.spdx.json
509+
510+
# Attest SBOM to multi-arch image
511+
echo "Attesting SBOM to multi-arch image: ${{ steps.build_docker.outputs.multiarch_image }}"
512+
cosign clean "${{ steps.build_docker.outputs.multiarch_image }}"
513+
cosign attest --type spdxjson \
514+
--predicate coder_${{ steps.version.outputs.version }}_sbom.spdx.json \
515+
--yes \
516+
"${{ steps.build_docker.outputs.multiarch_image }}"
517+
518+
# If latest tag was created, also attest it
519+
if [[ "${{ steps.build_docker.outputs.created_latest_tag }}" == "true" ]]; then
520+
latest_tag="$(./scripts/image_tag.sh --version latest)"
521+
echo "Generating SBOM for latest image: ${latest_tag}"
522+
syft "${latest_tag}" -o spdx-json > coder_latest_sbom.spdx.json
523+
524+
echo "Attesting SBOM to latest image: ${latest_tag}"
525+
cosign clean "${latest_tag}"
526+
cosign attest --type spdxjson \
527+
--predicate coder_latest_sbom.spdx.json \
528+
--yes \
529+
"${latest_tag}"
530+
fi
531+
499532
- name: GitHub Attestation for Docker image
500533
id: attest_main
501534
if: ${{ !inputs.dry_run }}
@@ -612,16 +645,27 @@ jobs:
612645
fi
613646
declare -p publish_args
614647
648+
# Build the list of files to publish
649+
files=(
650+
./build/*_installer.exe
651+
./build/*.zip
652+
./build/*.tar.gz
653+
./build/*.tgz
654+
./build/*.apk
655+
./build/*.deb
656+
./build/*.rpm
657+
./coder_${{ steps.version.outputs.version }}_sbom.spdx.json
658+
)
659+
660+
# Only include the latest SBOM file if it was created
661+
if [[ "${{ steps.build_docker.outputs.created_latest_tag }}" == "true" ]]; then
662+
files+=(./coder_latest_sbom.spdx.json)
663+
fi
664+
615665
./scripts/release/publish.sh \
616666
"${publish_args[@]}" \
617667
--release-notes-file "$CODER_RELEASE_NOTES_FILE" \
618-
./build/*_installer.exe \
619-
./build/*.zip \
620-
./build/*.tar.gz \
621-
./build/*.tgz \
622-
./build/*.apk \
623-
./build/*.deb \
624-
./build/*.rpm
668+
"${files[@]}"
625669
env:
626670
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
627671
CODER_GPG_RELEASE_KEY_BASE64: ${{ secrets.GPG_RELEASE_KEY_BASE64 }}
@@ -663,6 +707,15 @@ jobs:
663707
./build/*.apk
664708
./build/*.deb
665709
./build/*.rpm
710+
./coder_${{ steps.version.outputs.version }}_sbom.spdx.json
711+
retention-days: 7
712+
713+
- name: Upload latest sbom artifact to actions (if dry-run)
714+
if: inputs.dry_run && steps.build_docker.outputs.created_latest_tag == 'true'
715+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
716+
with:
717+
name: latest-sbom-artifact
718+
path: ./coder_latest_sbom.spdx.json
666719
retention-days: 7
667720

668721
- name: Send repository-dispatch event

agent/agent.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1408,7 +1408,7 @@ func (a *agent) createTailnet(
14081408
if rPTYServeErr != nil &&
14091409
a.gracefulCtx.Err() == nil &&
14101410
!strings.Contains(rPTYServeErr.Error(), "use of closed network connection") {
1411-
a.logger.Error(ctx, "error serving reconnecting PTY", slog.Error(err))
1411+
a.logger.Error(ctx, "error serving reconnecting PTY", slog.Error(rPTYServeErr))
14121412
}
14131413
}); err != nil {
14141414
return nil, err

cli/server.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,15 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
620620
return xerrors.Errorf("parse ssh config options %q: %w", vals.SSHConfig.SSHConfigOptions.String(), err)
621621
}
622622

623+
// The workspace hostname suffix is always interpreted as implicitly beginning with a single dot, so it is
624+
// a config error to explicitly include the dot. This ensures that we always interpret the suffix as a
625+
// separate DNS label, and not just an ordinary string suffix. E.g. a suffix of 'coder' will match
626+
// 'en.coder' but not 'encoder'.
627+
if strings.HasPrefix(vals.WorkspaceHostnameSuffix.String(), ".") {
628+
return xerrors.Errorf("you must omit any leading . in workspace hostname suffix: %s",
629+
vals.WorkspaceHostnameSuffix.String())
630+
}
631+
623632
options := &coderd.Options{
624633
AccessURL: vals.AccessURL.Value(),
625634
AppHostname: appHostname,

cli/ssh.go

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"os"
1414
"os/exec"
1515
"path/filepath"
16+
"regexp"
1617
"slices"
1718
"strconv"
1819
"strings"
@@ -57,12 +58,14 @@ var (
5758
autostopNotifyCountdown = []time.Duration{30 * time.Minute}
5859
// gracefulShutdownTimeout is the timeout, per item in the stack of things to close
5960
gracefulShutdownTimeout = 2 * time.Second
61+
workspaceNameRe = regexp.MustCompile(`[/.]+|--`)
6062
)
6163

6264
func (r *RootCmd) ssh() *serpent.Command {
6365
var (
6466
stdio bool
6567
hostPrefix string
68+
hostnameSuffix string
6669
forwardAgent bool
6770
forwardGPG bool
6871
identityAgent string
@@ -200,11 +203,14 @@ func (r *RootCmd) ssh() *serpent.Command {
200203
parsedEnv = append(parsedEnv, [2]string{k, v})
201204
}
202205

203-
namedWorkspace := strings.TrimPrefix(inv.Args[0], hostPrefix)
204-
// Support "--" as a delimiter between owner and workspace name
205-
namedWorkspace = strings.Replace(namedWorkspace, "--", "/", 1)
206+
deploymentSSHConfig := codersdk.SSHConfigResponse{
207+
HostnamePrefix: hostPrefix,
208+
HostnameSuffix: hostnameSuffix,
209+
}
206210

207-
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, namedWorkspace)
211+
workspace, workspaceAgent, err := findWorkspaceAndAgentByHostname(
212+
ctx, inv, client,
213+
inv.Args[0], deploymentSSHConfig, disableAutostart)
208214
if err != nil {
209215
return err
210216
}
@@ -563,6 +569,12 @@ func (r *RootCmd) ssh() *serpent.Command {
563569
Description: "Strip this prefix from the provided hostname to determine the workspace name. This is useful when used as part of an OpenSSH proxy command.",
564570
Value: serpent.StringOf(&hostPrefix),
565571
},
572+
{
573+
Flag: "hostname-suffix",
574+
Env: "CODER_SSH_HOSTNAME_SUFFIX",
575+
Description: "Strip this suffix from the provided hostname to determine the workspace name. This is useful when used as part of an OpenSSH proxy command. The suffix must be specified without a leading . character.",
576+
Value: serpent.StringOf(&hostnameSuffix),
577+
},
566578
{
567579
Flag: "forward-agent",
568580
FlagShorthand: "A",
@@ -655,6 +667,30 @@ func (r *RootCmd) ssh() *serpent.Command {
655667
return cmd
656668
}
657669

670+
// findWorkspaceAndAgentByHostname parses the hostname from the commandline and finds the workspace and agent it
671+
// corresponds to, taking into account any name prefixes or suffixes configured (e.g. myworkspace.coder, or
672+
// vscode-coder--myusername--myworkspace).
673+
func findWorkspaceAndAgentByHostname(
674+
ctx context.Context, inv *serpent.Invocation, client *codersdk.Client,
675+
hostname string, config codersdk.SSHConfigResponse, disableAutostart bool,
676+
) (
677+
codersdk.Workspace, codersdk.WorkspaceAgent, error,
678+
) {
679+
// for suffixes, we don't explicitly get the . and must add it. This is to ensure that the suffix is always
680+
// interpreted as a dotted label in DNS names, not just any string suffix. That is, a suffix of 'coder' will
681+
// match a hostname like 'en.coder', but not 'encoder'.
682+
qualifiedSuffix := "." + config.HostnameSuffix
683+
684+
switch {
685+
case config.HostnamePrefix != "" && strings.HasPrefix(hostname, config.HostnamePrefix):
686+
hostname = strings.TrimPrefix(hostname, config.HostnamePrefix)
687+
case config.HostnameSuffix != "" && strings.HasSuffix(hostname, qualifiedSuffix):
688+
hostname = strings.TrimSuffix(hostname, qualifiedSuffix)
689+
}
690+
hostname = normalizeWorkspaceInput(hostname)
691+
return getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, hostname)
692+
}
693+
658694
// watchAndClose ensures closer is called if the context is canceled or
659695
// the workspace reaches the stopped state.
660696
//
@@ -1413,3 +1449,28 @@ func collectNetworkStats(ctx context.Context, agentConn *workspacesdk.AgentConn,
14131449
DownloadBytesSec: int64(downloadSecs),
14141450
}, nil
14151451
}
1452+
1453+
// Converts workspace name input to owner/workspace.agent format
1454+
// Possible valid input formats:
1455+
// workspace
1456+
// owner/workspace
1457+
// owner--workspace
1458+
// owner/workspace--agent
1459+
// owner/workspace.agent
1460+
// owner--workspace--agent
1461+
// owner--workspace.agent
1462+
func normalizeWorkspaceInput(input string) string {
1463+
// Split on "/", "--", and "."
1464+
parts := workspaceNameRe.Split(input, -1)
1465+
1466+
switch len(parts) {
1467+
case 1:
1468+
return input // "workspace"
1469+
case 2:
1470+
return fmt.Sprintf("%s/%s", parts[0], parts[1]) // "owner/workspace"
1471+
case 3:
1472+
return fmt.Sprintf("%s/%s.%s", parts[0], parts[1], parts[2]) // "owner/workspace.agent"
1473+
default:
1474+
return input // Fallback
1475+
}
1476+
}

0 commit comments

Comments
 (0)